@aion0/forge 0.5.30 → 0.5.32
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/RELEASE_NOTES.md +3 -15
- package/components/DocsViewer.tsx +16 -5
- package/components/ProjectDetail.tsx +19 -3
- package/components/ProjectManager.tsx +140 -81
- package/components/SessionView.tsx +7 -0
- package/components/SkillsPanel.tsx +26 -6
- package/components/WorkspaceTree.tsx +6 -1
- package/components/WorkspaceView.tsx +51 -3
- package/lib/workspace/orchestrator.ts +15 -1
- package/lib/workspace/presets.ts +52 -5
- package/lib/workspace-standalone.ts +13 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
|
-
# Forge v0.5.
|
|
1
|
+
# Forge v0.5.32
|
|
2
2
|
|
|
3
3
|
Released: 2026-04-10
|
|
4
4
|
|
|
5
|
-
## Changes since v0.5.
|
|
5
|
+
## Changes since v0.5.31
|
|
6
6
|
|
|
7
|
-
### Features
|
|
8
|
-
- feat: shrink trend chart, add GitHub-style heatmap and weekday chart
|
|
9
|
-
- feat: richer usage statistics with charts
|
|
10
|
-
- feat: auto-expand docked terminals based on count
|
|
11
|
-
- feat: workspace terminal dock mode with grid layout
|
|
12
7
|
|
|
13
|
-
|
|
14
|
-
- fix: preserve workspace terminal state across tab/project switches
|
|
15
|
-
|
|
16
|
-
### Other
|
|
17
|
-
- style: make project tab buttons more prominent
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.29...v0.5.30
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.5.31...v0.5.32
|
|
@@ -28,14 +28,19 @@ interface FileNode {
|
|
|
28
28
|
|
|
29
29
|
// ─── File Tree ───────────────────────────────────────────
|
|
30
30
|
|
|
31
|
-
function TreeNode({ node, depth, selected, onSelect }: {
|
|
31
|
+
function TreeNode({ node, depth, selected, onSelect, collapseVersion = 0 }: {
|
|
32
32
|
node: FileNode;
|
|
33
33
|
depth: number;
|
|
34
34
|
selected: string | null;
|
|
35
35
|
onSelect: (path: string) => void;
|
|
36
|
+
collapseVersion?: number;
|
|
36
37
|
}) {
|
|
37
38
|
const [expanded, setExpanded] = useState(depth < 1);
|
|
38
39
|
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (collapseVersion > 0) setExpanded(false);
|
|
42
|
+
}, [collapseVersion]);
|
|
43
|
+
|
|
39
44
|
if (node.type === 'dir') {
|
|
40
45
|
return (
|
|
41
46
|
<div>
|
|
@@ -48,7 +53,7 @@ function TreeNode({ node, depth, selected, onSelect }: {
|
|
|
48
53
|
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
49
54
|
</button>
|
|
50
55
|
{expanded && node.children?.map(child => (
|
|
51
|
-
<TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
|
56
|
+
<TreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} collapseVersion={collapseVersion} />
|
|
52
57
|
))}
|
|
53
58
|
</div>
|
|
54
59
|
);
|
|
@@ -107,6 +112,7 @@ export default function DocsViewer() {
|
|
|
107
112
|
const [content, setContent] = useState<string | null>(null);
|
|
108
113
|
const [loading, setLoading] = useState(false);
|
|
109
114
|
const [search, setSearch] = useState('');
|
|
115
|
+
const [treeCollapseVersion, setTreeCollapseVersion] = useState(0);
|
|
110
116
|
const [terminalHeight, setTerminalHeight] = useState(250);
|
|
111
117
|
const [docsAgent, setDocsAgent] = useState('');
|
|
112
118
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
@@ -387,14 +393,19 @@ export default function DocsViewer() {
|
|
|
387
393
|
</div>
|
|
388
394
|
|
|
389
395
|
{/* Search */}
|
|
390
|
-
<div className="p-2 border-b border-[var(--border)]">
|
|
396
|
+
<div className="p-2 border-b border-[var(--border)] flex items-center gap-2">
|
|
391
397
|
<input
|
|
392
398
|
type="text"
|
|
393
399
|
placeholder="Search..."
|
|
394
400
|
value={search}
|
|
395
401
|
onChange={e => setSearch(e.target.value)}
|
|
396
|
-
className="
|
|
402
|
+
className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
397
403
|
/>
|
|
404
|
+
<button
|
|
405
|
+
onClick={() => setTreeCollapseVersion(v => v + 1)}
|
|
406
|
+
className="text-[10px] px-1.5 py-1 rounded text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] shrink-0"
|
|
407
|
+
title="Collapse all folders"
|
|
408
|
+
>⇱</button>
|
|
398
409
|
</div>
|
|
399
410
|
|
|
400
411
|
{/* Tree / search results */}
|
|
@@ -419,7 +430,7 @@ export default function DocsViewer() {
|
|
|
419
430
|
)
|
|
420
431
|
) : (
|
|
421
432
|
(hideUnsupported ? filterTree(tree) : tree).map(node => (
|
|
422
|
-
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} />
|
|
433
|
+
<TreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFileInTab} collapseVersion={treeCollapseVersion} />
|
|
423
434
|
))
|
|
424
435
|
)}
|
|
425
436
|
</div>
|
|
@@ -67,6 +67,7 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
67
67
|
const [gitLoading, setGitLoading] = useState(false);
|
|
68
68
|
const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
|
|
69
69
|
const [fileTree, setFileTree] = useState<any[]>([]);
|
|
70
|
+
const [treeCollapseVersion, setTreeCollapseVersion] = useState(0);
|
|
70
71
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
|
71
72
|
const [fileContent, setFileContent] = useState<string | null>(null);
|
|
72
73
|
const [fileImageUrl, setFileImageUrl] = useState<string | null>(null);
|
|
@@ -713,10 +714,19 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
713
714
|
</div>
|
|
714
715
|
)}
|
|
715
716
|
{codeSearching && <div className="px-2 py-1 text-[9px] text-[var(--text-secondary)]">Searching...</div>}
|
|
717
|
+
{/* File tree header */}
|
|
718
|
+
<div className="px-2 py-0.5 border-b border-[var(--border)] flex items-center justify-between">
|
|
719
|
+
<span className="text-[8px] text-[var(--text-secondary)] uppercase">Files</span>
|
|
720
|
+
<button
|
|
721
|
+
onClick={() => setTreeCollapseVersion(v => v + 1)}
|
|
722
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
723
|
+
title="Collapse all folders"
|
|
724
|
+
>⇱ collapse all</button>
|
|
725
|
+
</div>
|
|
716
726
|
{/* File tree */}
|
|
717
727
|
<div className="overflow-y-auto flex-1 p-1">
|
|
718
728
|
{fileTree.map((node: any) => (
|
|
719
|
-
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
|
|
729
|
+
<FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} collapseVersion={treeCollapseVersion} />
|
|
720
730
|
))}
|
|
721
731
|
</div>
|
|
722
732
|
</div>
|
|
@@ -1468,14 +1478,20 @@ export default memo(function ProjectDetail({ projectPath, projectName, hasGit }:
|
|
|
1468
1478
|
});
|
|
1469
1479
|
|
|
1470
1480
|
// Simple file tree node
|
|
1471
|
-
const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect }: {
|
|
1481
|
+
const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelect, collapseVersion }: {
|
|
1472
1482
|
node: { name: string; path: string; type: string; children?: any[] };
|
|
1473
1483
|
depth: number;
|
|
1474
1484
|
selected: string | null;
|
|
1475
1485
|
onSelect: (path: string) => void;
|
|
1486
|
+
collapseVersion: number;
|
|
1476
1487
|
}) {
|
|
1477
1488
|
const [expanded, setExpanded] = useState(depth < 1);
|
|
1478
1489
|
|
|
1490
|
+
// When parent bumps collapseVersion, collapse this node
|
|
1491
|
+
useEffect(() => {
|
|
1492
|
+
if (collapseVersion > 0) setExpanded(false);
|
|
1493
|
+
}, [collapseVersion]);
|
|
1494
|
+
|
|
1479
1495
|
if (node.type === 'dir') {
|
|
1480
1496
|
return (
|
|
1481
1497
|
<div>
|
|
@@ -1488,7 +1504,7 @@ const FileTreeNode = memo(function FileTreeNode({ node, depth, selected, onSelec
|
|
|
1488
1504
|
<span className="text-[var(--text-primary)]">{node.name}</span>
|
|
1489
1505
|
</button>
|
|
1490
1506
|
{expanded && node.children?.map((child: any) => (
|
|
1491
|
-
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
|
|
1507
|
+
<FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} collapseVersion={collapseVersion} />
|
|
1492
1508
|
))}
|
|
1493
1509
|
</div>
|
|
1494
1510
|
);
|
|
@@ -26,7 +26,17 @@ function genTabId(): number { return Date.now() + Math.floor(Math.random() * 100
|
|
|
26
26
|
|
|
27
27
|
export default function ProjectManager() {
|
|
28
28
|
const { sidebarWidth, onSidebarDragStart } = useSidebarResize({ defaultWidth: 240, minWidth: 140, maxWidth: 400 });
|
|
29
|
-
const [sidebarCollapsed,
|
|
29
|
+
const [sidebarCollapsed, setSidebarCollapsedState] = useState(false);
|
|
30
|
+
// Load collapsed preference from localStorage on mount
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
try {
|
|
33
|
+
if (localStorage.getItem('forge.pm.sidebarCollapsed') === '1') setSidebarCollapsedState(true);
|
|
34
|
+
} catch {}
|
|
35
|
+
}, []);
|
|
36
|
+
const setSidebarCollapsed = useCallback((v: boolean) => {
|
|
37
|
+
setSidebarCollapsedState(v);
|
|
38
|
+
try { localStorage.setItem('forge.pm.sidebarCollapsed', v ? '1' : '0'); } catch {}
|
|
39
|
+
}, []);
|
|
30
40
|
const [projects, setProjects] = useState<Project[]>([]);
|
|
31
41
|
const [showClone, setShowClone] = useState(false);
|
|
32
42
|
const [cloneUrl, setCloneUrl] = useState('');
|
|
@@ -103,40 +113,37 @@ export default function ProjectManager() {
|
|
|
103
113
|
|
|
104
114
|
// Open a project in a tab
|
|
105
115
|
const openProjectTab = useCallback((p: Project) => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return updated;
|
|
127
|
-
});
|
|
128
|
-
}, [persistTabs]);
|
|
116
|
+
const existing = tabs.find(t => t.projectPath === p.path);
|
|
117
|
+
if (existing) {
|
|
118
|
+
const updated = tabs.map(t => t.id === existing.id ? { ...t, mountedAt: Date.now() } : t);
|
|
119
|
+
setTabs(updated);
|
|
120
|
+
setActiveTabId(existing.id);
|
|
121
|
+
persistTabs(updated, existing.id);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const newTab: ProjectTab = {
|
|
125
|
+
id: genTabId(),
|
|
126
|
+
projectPath: p.path,
|
|
127
|
+
projectName: p.name,
|
|
128
|
+
hasGit: p.hasGit,
|
|
129
|
+
mountedAt: Date.now(),
|
|
130
|
+
};
|
|
131
|
+
const updated = [...tabs, newTab];
|
|
132
|
+
setTabs(updated);
|
|
133
|
+
setActiveTabId(newTab.id);
|
|
134
|
+
persistTabs(updated, newTab.id);
|
|
135
|
+
}, [tabs, persistTabs]);
|
|
129
136
|
|
|
130
137
|
const activateTab = useCallback((id: number) => {
|
|
138
|
+
const updated = tabs.map(t => t.id === id ? { ...t, mountedAt: Date.now() } : t);
|
|
139
|
+
setTabs(updated);
|
|
131
140
|
setActiveTabId(id);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
persistTabs(updated, id);
|
|
135
|
-
return updated;
|
|
136
|
-
});
|
|
137
|
-
}, [persistTabs]);
|
|
141
|
+
persistTabs(updated, id);
|
|
142
|
+
}, [tabs, persistTabs]);
|
|
138
143
|
|
|
139
144
|
const closeTab = useCallback((id: number) => {
|
|
145
|
+
const tab = tabs.find(t => t.id === id);
|
|
146
|
+
if (tab && !window.confirm(`Close "${tab.projectName}"?`)) return;
|
|
140
147
|
setTabs(prev => {
|
|
141
148
|
const idx = prev.findIndex(t => t.id === id);
|
|
142
149
|
const updated = prev.filter(t => t.id !== id);
|
|
@@ -154,7 +161,7 @@ export default function ProjectManager() {
|
|
|
154
161
|
}
|
|
155
162
|
return updated;
|
|
156
163
|
});
|
|
157
|
-
}, [activeTabId, persistTabs]);
|
|
164
|
+
}, [activeTabId, persistTabs, tabs]);
|
|
158
165
|
|
|
159
166
|
// Determine which tabs to mount (max 5, LRU eviction)
|
|
160
167
|
const mountedTabIds = new Set<number>();
|
|
@@ -213,7 +220,7 @@ export default function ProjectManager() {
|
|
|
213
220
|
|
|
214
221
|
return (
|
|
215
222
|
<div className="flex-1 flex min-h-0">
|
|
216
|
-
{/* Collapsed sidebar — narrow strip with
|
|
223
|
+
{/* Collapsed sidebar — narrow strip with open tabs on top, all projects below */}
|
|
217
224
|
{sidebarCollapsed && (
|
|
218
225
|
<div className="w-10 border-r border-[var(--border)] flex flex-col shrink-0 overflow-hidden">
|
|
219
226
|
<button onClick={() => setSidebarCollapsed(false)}
|
|
@@ -221,17 +228,46 @@ export default function ProjectManager() {
|
|
|
221
228
|
title="Expand sidebar">
|
|
222
229
|
▶
|
|
223
230
|
</button>
|
|
231
|
+
{/* Open tabs */}
|
|
232
|
+
{tabs.length > 0 && (
|
|
233
|
+
<>
|
|
234
|
+
<div className="text-[7px] text-[var(--text-secondary)] text-center py-0.5 border-y border-[var(--border)] bg-[var(--bg-tertiary)]">OPEN</div>
|
|
235
|
+
{tabs.map(t => {
|
|
236
|
+
const isActive = t.id === activeTabId;
|
|
237
|
+
const initial = t.projectName.slice(0, 2).toUpperCase();
|
|
238
|
+
return (
|
|
239
|
+
<div key={`tab-${t.id}`} className="group relative">
|
|
240
|
+
<button
|
|
241
|
+
onClick={() => activateTab(t.id)}
|
|
242
|
+
title={t.projectName}
|
|
243
|
+
className={`w-full py-1.5 text-[9px] font-bold text-center border-l-2 ${
|
|
244
|
+
isActive ? 'text-[var(--accent)] bg-[var(--accent)]/10 border-[var(--accent)]' : 'text-[var(--green)] border-transparent hover:bg-[var(--bg-secondary)]'
|
|
245
|
+
}`}
|
|
246
|
+
>
|
|
247
|
+
{initial}
|
|
248
|
+
</button>
|
|
249
|
+
<button
|
|
250
|
+
onClick={(e) => { e.stopPropagation(); closeTab(t.id); }}
|
|
251
|
+
className="absolute top-0 right-0 text-[8px] text-[var(--text-secondary)] hover:text-[var(--red)] opacity-0 group-hover:opacity-100 transition-opacity px-0.5"
|
|
252
|
+
title="Close"
|
|
253
|
+
>×</button>
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
})}
|
|
257
|
+
<div className="border-b border-[var(--border)] my-1"></div>
|
|
258
|
+
</>
|
|
259
|
+
)}
|
|
260
|
+
{/* All projects */}
|
|
224
261
|
<div className="flex-1 overflow-y-auto">
|
|
225
262
|
{[...projects].sort((a, b) => a.name.localeCompare(b.name)).map(p => {
|
|
226
|
-
const
|
|
263
|
+
const openTab = tabs.find(t => t.projectPath === p.path);
|
|
264
|
+
if (openTab) return null; // already in Open section above
|
|
227
265
|
const initial = p.name.slice(0, 2).toUpperCase();
|
|
228
266
|
return (
|
|
229
267
|
<button key={p.path}
|
|
230
268
|
onClick={() => openProjectTab(p)}
|
|
231
269
|
title={p.name}
|
|
232
|
-
className=
|
|
233
|
-
isActive ? 'text-[var(--accent)] bg-[var(--accent)]/10' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]'
|
|
234
|
-
}`}>
|
|
270
|
+
className="w-full py-1.5 text-[9px] font-bold text-center text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]">
|
|
235
271
|
{initial}
|
|
236
272
|
</button>
|
|
237
273
|
);
|
|
@@ -285,24 +321,40 @@ export default function ProjectManager() {
|
|
|
285
321
|
<span className="text-[8px]">{collapsedRoots.has('__favorites__') ? '▸' : '▾'}</span>
|
|
286
322
|
<span>★</span> Favorites ({favoriteProjects.length})
|
|
287
323
|
</button>
|
|
288
|
-
{!collapsedRoots.has('__favorites__') && favoriteProjects.map(p =>
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
324
|
+
{!collapsedRoots.has('__favorites__') && favoriteProjects.map(p => {
|
|
325
|
+
const openTab = tabs.find(t => t.projectPath === p.path);
|
|
326
|
+
const isActive = openTab?.id === activeTabId;
|
|
327
|
+
return (
|
|
328
|
+
<div
|
|
329
|
+
key={`fav-${p.path}`}
|
|
330
|
+
className={`group w-full border-b border-[var(--border)]/30 flex items-center gap-2 px-3 py-1.5 text-xs ${
|
|
331
|
+
isActive ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : openTab ? 'bg-[var(--bg-tertiary)]/40 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
332
|
+
}`}
|
|
333
|
+
>
|
|
334
|
+
<span
|
|
335
|
+
onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
|
|
336
|
+
className="text-[13px] text-[var(--yellow)] shrink-0 cursor-pointer leading-none"
|
|
337
|
+
title="Remove from favorites"
|
|
338
|
+
>★</span>
|
|
339
|
+
<button
|
|
340
|
+
onClick={() => openProjectTab(p)}
|
|
341
|
+
className="flex-1 text-left flex items-center gap-2 min-w-0"
|
|
342
|
+
>
|
|
343
|
+
{openTab && <span className="w-1.5 h-1.5 rounded-full bg-[var(--green)] shrink-0" title="Open"></span>}
|
|
344
|
+
<span className="truncate">{p.name}</span>
|
|
345
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
346
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
347
|
+
</button>
|
|
348
|
+
{openTab && (
|
|
349
|
+
<button
|
|
350
|
+
onClick={(e) => { e.stopPropagation(); closeTab(openTab.id); }}
|
|
351
|
+
className="text-[var(--text-secondary)] hover:text-[var(--red)] text-[11px] leading-none opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
352
|
+
title="Close tab"
|
|
353
|
+
>×</button>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
);
|
|
357
|
+
})}
|
|
306
358
|
</div>
|
|
307
359
|
)}
|
|
308
360
|
|
|
@@ -320,24 +372,40 @@ export default function ProjectManager() {
|
|
|
320
372
|
<span className="text-[8px]">{isCollapsed ? '▸' : '▾'}</span>
|
|
321
373
|
{rootName} ({rootProjects.length})
|
|
322
374
|
</button>
|
|
323
|
-
{!isCollapsed && rootProjects.map(p =>
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
375
|
+
{!isCollapsed && rootProjects.map(p => {
|
|
376
|
+
const openTab = tabs.find(t => t.projectPath === p.path);
|
|
377
|
+
const isActive = openTab?.id === activeTabId;
|
|
378
|
+
return (
|
|
379
|
+
<div
|
|
380
|
+
key={p.path}
|
|
381
|
+
className={`group w-full border-b border-[var(--border)]/30 flex items-center gap-2 px-3 py-1.5 text-xs ${
|
|
382
|
+
isActive ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : openTab ? 'bg-[var(--bg-tertiary)]/40 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
|
|
383
|
+
}`}
|
|
384
|
+
>
|
|
385
|
+
<span
|
|
386
|
+
onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
|
|
387
|
+
className={`text-[13px] shrink-0 cursor-pointer leading-none ${favorites.includes(p.path) ? 'text-[var(--yellow)]' : 'text-[var(--text-secondary)]/30 hover:text-[var(--yellow)]'}`}
|
|
388
|
+
title={favorites.includes(p.path) ? 'Remove from favorites' : 'Add to favorites'}
|
|
389
|
+
>{favorites.includes(p.path) ? '★' : '☆'}</span>
|
|
390
|
+
<button
|
|
391
|
+
onClick={() => openProjectTab(p)}
|
|
392
|
+
className="flex-1 text-left flex items-center gap-2 min-w-0"
|
|
393
|
+
>
|
|
394
|
+
{openTab && <span className="w-1.5 h-1.5 rounded-full bg-[var(--green)] shrink-0" title="Open"></span>}
|
|
395
|
+
<span className="truncate">{p.name}</span>
|
|
396
|
+
{p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
|
|
397
|
+
{p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
|
|
398
|
+
</button>
|
|
399
|
+
{openTab && (
|
|
400
|
+
<button
|
|
401
|
+
onClick={(e) => { e.stopPropagation(); closeTab(openTab.id); }}
|
|
402
|
+
className="text-[var(--text-secondary)] hover:text-[var(--red)] text-[11px] leading-none opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
|
403
|
+
title="Close tab"
|
|
404
|
+
>×</button>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
341
409
|
</div>
|
|
342
410
|
);
|
|
343
411
|
})}
|
|
@@ -356,15 +424,6 @@ export default function ProjectManager() {
|
|
|
356
424
|
|
|
357
425
|
{/* Main area */}
|
|
358
426
|
<div className="flex-1 flex flex-col min-w-0">
|
|
359
|
-
{/* Tab bar */}
|
|
360
|
-
{tabs.length > 0 && (
|
|
361
|
-
<TabBar
|
|
362
|
-
tabs={tabs.map(t => ({ id: t.id, label: t.projectName }))}
|
|
363
|
-
activeId={activeTabId}
|
|
364
|
-
onActivate={activateTab}
|
|
365
|
-
onClose={closeTab}
|
|
366
|
-
/>
|
|
367
|
-
)}
|
|
368
427
|
|
|
369
428
|
{/* Tab content */}
|
|
370
429
|
{tabs.length > 0 ? (
|
|
@@ -319,6 +319,13 @@ export default function SessionView({
|
|
|
319
319
|
<div className="flex items-center justify-between p-2 border-b border-[var(--border)]">
|
|
320
320
|
<span className="text-[10px] font-semibold text-[var(--text-secondary)] uppercase">Sessions</span>
|
|
321
321
|
<div className="flex items-center gap-2">
|
|
322
|
+
{!singleProject && (
|
|
323
|
+
<button
|
|
324
|
+
onClick={() => setExpandedProjects(new Set())}
|
|
325
|
+
className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
326
|
+
title="Collapse all projects"
|
|
327
|
+
>⇱</button>
|
|
328
|
+
)}
|
|
322
329
|
<button
|
|
323
330
|
onClick={() => batchMode ? exitBatchMode() : setBatchMode(true)}
|
|
324
331
|
className={`text-[9px] transition-colors ${batchMode ? 'text-[var(--accent)]' : 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'}`}
|
|
@@ -64,23 +64,29 @@ function buildTree(files: { name: string; path: string; type: string }[]): TreeN
|
|
|
64
64
|
return root;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
function SkillFileTree({ files, activeFile, onSelect }: {
|
|
67
|
+
function SkillFileTree({ files, activeFile, onSelect, collapseVersion = 0 }: {
|
|
68
68
|
files: { name: string; path: string; type: string }[];
|
|
69
69
|
activeFile: string | null;
|
|
70
70
|
onSelect: (path: string) => void;
|
|
71
|
+
collapseVersion?: number;
|
|
71
72
|
}) {
|
|
72
73
|
const tree = buildTree(files);
|
|
73
|
-
return <TreeNodeList nodes={tree} depth={0} activeFile={activeFile} onSelect={onSelect} />;
|
|
74
|
+
return <TreeNodeList nodes={tree} depth={0} activeFile={activeFile} onSelect={onSelect} collapseVersion={collapseVersion} />;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
function TreeNodeList({ nodes, depth, activeFile, onSelect }: {
|
|
77
|
-
nodes: TreeNode[]; depth: number; activeFile: string | null; onSelect: (path: string) => void;
|
|
77
|
+
function TreeNodeList({ nodes, depth, activeFile, onSelect, collapseVersion = 0 }: {
|
|
78
|
+
nodes: TreeNode[]; depth: number; activeFile: string | null; onSelect: (path: string) => void; collapseVersion?: number;
|
|
78
79
|
}) {
|
|
79
80
|
const [expanded, setExpanded] = useState<Set<string>>(new Set(
|
|
80
81
|
// Auto-expand first level
|
|
81
82
|
nodes.filter(n => n.type === 'dir').map(n => n.path)
|
|
82
83
|
));
|
|
83
84
|
|
|
85
|
+
// Collapse all on version bump
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (collapseVersion > 0) setExpanded(new Set());
|
|
88
|
+
}, [collapseVersion]);
|
|
89
|
+
|
|
84
90
|
const toggle = (path: string) => {
|
|
85
91
|
setExpanded(prev => {
|
|
86
92
|
const next = new Set(prev);
|
|
@@ -103,7 +109,7 @@ function TreeNodeList({ nodes, depth, activeFile, onSelect }: {
|
|
|
103
109
|
<span>📁 {node.name}</span>
|
|
104
110
|
</button>
|
|
105
111
|
{expanded.has(node.path) && (
|
|
106
|
-
<TreeNodeList nodes={node.children} depth={depth + 1} activeFile={activeFile} onSelect={onSelect} />
|
|
112
|
+
<TreeNodeList nodes={node.children} depth={depth + 1} activeFile={activeFile} onSelect={onSelect} collapseVersion={collapseVersion} />
|
|
107
113
|
)}
|
|
108
114
|
</div>
|
|
109
115
|
) : (
|
|
@@ -152,6 +158,7 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
152
158
|
const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
|
|
153
159
|
const [skillFiles, setSkillFiles] = useState<{ name: string; path: string; type: string }[]>([]);
|
|
154
160
|
const [activeFile, setActiveFile] = useState<string | null>(null);
|
|
161
|
+
const [skillTreeCollapseVersion, setSkillTreeCollapseVersion] = useState(0);
|
|
155
162
|
const [fileContent, setFileContent] = useState<string>('');
|
|
156
163
|
|
|
157
164
|
const fetchSkills = useCallback(async () => {
|
|
@@ -742,7 +749,18 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
742
749
|
{/* File browser */}
|
|
743
750
|
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
744
751
|
{/* File tree */}
|
|
745
|
-
<div className="w-36 border-r border-[var(--border)]
|
|
752
|
+
<div className="w-36 border-r border-[var(--border)] flex flex-col shrink-0">
|
|
753
|
+
{skillFiles.length > 0 && (
|
|
754
|
+
<div className="px-2 py-0.5 border-b border-[var(--border)] flex items-center justify-between shrink-0">
|
|
755
|
+
<span className="text-[8px] text-[var(--text-secondary)] uppercase">Files</span>
|
|
756
|
+
<button
|
|
757
|
+
onClick={() => setSkillTreeCollapseVersion(v => v + 1)}
|
|
758
|
+
className="text-[8px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
|
759
|
+
title="Collapse all"
|
|
760
|
+
>⇱</button>
|
|
761
|
+
</div>
|
|
762
|
+
)}
|
|
763
|
+
<div className="overflow-y-auto flex-1">
|
|
746
764
|
{skillFiles.length === 0 ? (
|
|
747
765
|
<div className="p-2 text-[9px] text-[var(--text-secondary)]">Loading...</div>
|
|
748
766
|
) : (
|
|
@@ -750,8 +768,10 @@ export default function SkillsPanel({ projectFilter }: { projectFilter?: string
|
|
|
750
768
|
files={skillFiles}
|
|
751
769
|
activeFile={activeFile}
|
|
752
770
|
onSelect={(path) => loadFile(itemName, path, isLocal, localItem?.type, localItem?.projectPath)}
|
|
771
|
+
collapseVersion={skillTreeCollapseVersion}
|
|
753
772
|
/>
|
|
754
773
|
)}
|
|
774
|
+
</div>
|
|
755
775
|
{skill?.sourceUrl && (
|
|
756
776
|
<div className="border-t border-[var(--border)] p-2">
|
|
757
777
|
<a
|
|
@@ -126,8 +126,13 @@ export default function WorkspaceTree({
|
|
|
126
126
|
{/* Header */}
|
|
127
127
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-[var(--border)] shrink-0">
|
|
128
128
|
<span className="text-xs font-bold text-[var(--text-primary)]">Workspace</span>
|
|
129
|
+
<button onClick={() => setExpanded(new Set())}
|
|
130
|
+
className="text-[8px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
|
|
131
|
+
title="Collapse all">
|
|
132
|
+
⇱
|
|
133
|
+
</button>
|
|
129
134
|
<button onClick={onCreateWorkspace}
|
|
130
|
-
className="text-[8px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]
|
|
135
|
+
className="text-[8px] px-1.5 py-0.5 rounded border border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
|
|
131
136
|
+
|
|
132
137
|
</button>
|
|
133
138
|
</div>
|
|
@@ -2244,9 +2244,24 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2244
2244
|
}, [initialPos?.x, initialPos?.y]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
2245
2245
|
const [size, setSize] = useState({ w: 500, h: 300 });
|
|
2246
2246
|
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
|
2247
|
+
const [mouseOn, setMouseOn] = useState(true);
|
|
2247
2248
|
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
|
2248
2249
|
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
|
2249
2250
|
|
|
2251
|
+
const toggleMouse = () => {
|
|
2252
|
+
const next = !mouseOn;
|
|
2253
|
+
setMouseOn(next);
|
|
2254
|
+
// Send via current WebSocket (shared for all workspace terminals)
|
|
2255
|
+
try {
|
|
2256
|
+
const ws = new WebSocket(getWsUrl());
|
|
2257
|
+
ws.onopen = () => {
|
|
2258
|
+
ws.send(JSON.stringify({ type: 'tmux-mouse', mouse: next }));
|
|
2259
|
+
setTimeout(() => ws.close(), 300);
|
|
2260
|
+
};
|
|
2261
|
+
ws.onerror = () => ws.close();
|
|
2262
|
+
} catch {}
|
|
2263
|
+
};
|
|
2264
|
+
|
|
2250
2265
|
useEffect(() => {
|
|
2251
2266
|
const el = containerRef.current;
|
|
2252
2267
|
if (!el) return;
|
|
@@ -2463,7 +2478,15 @@ function FloatingTerminal({ agentLabel, agentIcon, projectPath, agentCliId, cliC
|
|
|
2463
2478
|
<span className="text-sm">{agentIcon}</span>
|
|
2464
2479
|
<span className="text-[11px] font-semibold text-white truncate">{agentLabel}</span>
|
|
2465
2480
|
{!docked && <span className="text-[8px] text-gray-500">⌨️ manual terminal</span>}
|
|
2466
|
-
<button
|
|
2481
|
+
<button
|
|
2482
|
+
onClick={(e) => { e.stopPropagation(); toggleMouse(); }}
|
|
2483
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
2484
|
+
className={`ml-auto text-[9px] px-1.5 py-0.5 rounded border transition-colors ${mouseOn ? 'border-green-600/40 text-green-400 bg-green-500/10' : 'border-gray-600 text-gray-500 bg-gray-800/50'}`}
|
|
2485
|
+
title={mouseOn ? 'Mouse ON (trackpad scroll, Shift+drag to select text)' : 'Mouse OFF (drag to select text, Ctrl+B [ to scroll)'}
|
|
2486
|
+
>
|
|
2487
|
+
🖱️ {mouseOn ? 'ON' : 'OFF'}
|
|
2488
|
+
</button>
|
|
2489
|
+
<button onClick={() => setShowCloseDialog(true)} className="text-gray-500 hover:text-white text-sm shrink-0">✕</button>
|
|
2467
2490
|
</div>
|
|
2468
2491
|
|
|
2469
2492
|
{/* Terminal */}
|
|
@@ -3497,6 +3520,29 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3497
3520
|
ensureWorkspace(projectPath, projectName).then(setWorkspaceId).catch(() => {});
|
|
3498
3521
|
}, [projectPath, projectName]);
|
|
3499
3522
|
|
|
3523
|
+
// Saved node positions from server (loaded once on workspace init)
|
|
3524
|
+
const [savedPositions, setSavedPositions] = useState<Record<string, { x: number; y: number }>>({});
|
|
3525
|
+
useEffect(() => {
|
|
3526
|
+
if (!workspaceId) return;
|
|
3527
|
+
wsApi(workspaceId, 'get_positions').then((res: any) => {
|
|
3528
|
+
if (res?.positions) setSavedPositions(res.positions);
|
|
3529
|
+
}).catch(() => {});
|
|
3530
|
+
}, [workspaceId]);
|
|
3531
|
+
|
|
3532
|
+
// Save positions (debounced) when nodes are dragged
|
|
3533
|
+
const savePositionsDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
3534
|
+
const saveNodePositions = useCallback(() => {
|
|
3535
|
+
if (!workspaceId) return;
|
|
3536
|
+
if (savePositionsDebounceRef.current) clearTimeout(savePositionsDebounceRef.current);
|
|
3537
|
+
savePositionsDebounceRef.current = setTimeout(() => {
|
|
3538
|
+
const positions: Record<string, { x: number; y: number }> = {};
|
|
3539
|
+
for (const n of rfNodes) {
|
|
3540
|
+
positions[n.id] = { x: n.position.x, y: n.position.y };
|
|
3541
|
+
}
|
|
3542
|
+
wsApi(workspaceId, 'set_positions', { positions }).catch(() => {});
|
|
3543
|
+
}, 500);
|
|
3544
|
+
}, [workspaceId, rfNodes]);
|
|
3545
|
+
|
|
3500
3546
|
// SSE stream — server is the single source of truth
|
|
3501
3547
|
const { agents, states, logPreview, busLog, daemonActive: daemonActiveFromStream, setDaemonActive: setDaemonActiveFromStream } = useWorkspaceStream(workspaceId, (event) => {
|
|
3502
3548
|
if (event.type === 'user_input_request') {
|
|
@@ -3515,7 +3561,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3515
3561
|
const existing = prevMap.get(agent.id);
|
|
3516
3562
|
const base = {
|
|
3517
3563
|
id: agent.id,
|
|
3518
|
-
position: existing?.position ?? { x: i * 260, y: 60 },
|
|
3564
|
+
position: existing?.position ?? savedPositions[agent.id] ?? { x: i * 260, y: 60 },
|
|
3519
3565
|
...(existing?.measured ? { measured: existing.measured } : {}),
|
|
3520
3566
|
...(existing?.width ? { width: existing.width, height: existing.height } : {}),
|
|
3521
3567
|
};
|
|
@@ -3625,7 +3671,7 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3625
3671
|
};
|
|
3626
3672
|
});
|
|
3627
3673
|
});
|
|
3628
|
-
}, [agents, states, logPreview, workspaceId, mascotTheme]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3674
|
+
}, [agents, states, logPreview, workspaceId, mascotTheme, savedPositions]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
3629
3675
|
|
|
3630
3676
|
// Derive edges from dependsOn
|
|
3631
3677
|
const rfEdges = useMemo(() => {
|
|
@@ -3920,6 +3966,8 @@ function WorkspaceViewInner({ projectPath, projectName, onClose }: {
|
|
|
3920
3966
|
edges={rfEdges}
|
|
3921
3967
|
onNodesChange={onNodesChange}
|
|
3922
3968
|
onNodeDragStop={() => {
|
|
3969
|
+
// Persist positions
|
|
3970
|
+
saveNodePositions();
|
|
3923
3971
|
// Reposition terminals to follow their nodes
|
|
3924
3972
|
setFloatingTerminals(prev => prev.map(ft => {
|
|
3925
3973
|
const nodeEl = document.querySelector(`[data-id="${ft.agentId}"]`);
|
|
@@ -96,6 +96,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
96
96
|
private reconcileTick = 0; // counts health check ticks for 60s reconcile
|
|
97
97
|
private _topoCache: WorkspaceTopo | null = null; // cached workspace topology
|
|
98
98
|
private roleInjectState = new Map<string, { lastInjectAt: number; msgsSinceInject: number }>(); // per-agent role reminder tracking
|
|
99
|
+
private nodePositions: Record<string, { x: number; y: number }> = {}; // persisted smith positions in ReactFlow graph
|
|
99
100
|
|
|
100
101
|
/** Emit a log event (auto-persisted via constructor listener) */
|
|
101
102
|
emitLog(agentId: string, entry: any): void {
|
|
@@ -1847,6 +1848,17 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1847
1848
|
return this.bus.getLog();
|
|
1848
1849
|
}
|
|
1849
1850
|
|
|
1851
|
+
// ─── Node positions (ReactFlow layout) ─────────────────
|
|
1852
|
+
|
|
1853
|
+
getNodePositions(): Record<string, { x: number; y: number }> {
|
|
1854
|
+
return { ...this.nodePositions };
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
setNodePositions(positions: Record<string, { x: number; y: number }>): void {
|
|
1858
|
+
this.nodePositions = { ...this.nodePositions, ...positions };
|
|
1859
|
+
this.saveNow();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1850
1862
|
// ─── State Snapshot (for persistence) ──────────────────
|
|
1851
1863
|
|
|
1852
1864
|
/** Get full workspace state for auto-save */
|
|
@@ -1857,7 +1869,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1857
1869
|
projectName: this.projectName,
|
|
1858
1870
|
agents: Array.from(this.agents.values()).map(e => e.config),
|
|
1859
1871
|
agentStates: this.getAllAgentStates(),
|
|
1860
|
-
nodePositions:
|
|
1872
|
+
nodePositions: this.nodePositions,
|
|
1861
1873
|
busLog: [...this.bus.getLog()],
|
|
1862
1874
|
busOutbox: this.bus.getAllOutbox(),
|
|
1863
1875
|
createdAt: this.createdAt,
|
|
@@ -1885,7 +1897,9 @@ export class WorkspaceOrchestrator extends EventEmitter {
|
|
|
1885
1897
|
agentStates: Record<string, AgentState>;
|
|
1886
1898
|
busLog: BusMessage[];
|
|
1887
1899
|
busOutbox?: Record<string, BusMessage[]>;
|
|
1900
|
+
nodePositions?: Record<string, { x: number; y: number }>;
|
|
1888
1901
|
}): void {
|
|
1902
|
+
if (data.nodePositions) this.nodePositions = { ...data.nodePositions };
|
|
1889
1903
|
this.agents.clear();
|
|
1890
1904
|
this.daemonActive = false; // Reset daemon — user must click Start Daemon again after restart
|
|
1891
1905
|
for (const config of data.agents) {
|
package/lib/workspace/presets.ts
CHANGED
|
@@ -22,6 +22,43 @@ import type { WorkspaceAgentConfig } from './types';
|
|
|
22
22
|
|
|
23
23
|
type PresetTemplate = Omit<WorkspaceAgentConfig, 'id'>;
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Shared decision rule for all smiths: when to use request documents
|
|
27
|
+
* vs inbox messages. This is the most common source of confusion.
|
|
28
|
+
*/
|
|
29
|
+
const REQUEST_VS_INBOX_RULE = `## Rule: Request vs Inbox
|
|
30
|
+
|
|
31
|
+
Use **request document** (create_request / claim_request / update_response) when:
|
|
32
|
+
- Delegating substantive work to another smith (implement feature, write tests, do review)
|
|
33
|
+
- Work has concrete deliverables and acceptance criteria
|
|
34
|
+
- Work should flow through a pipeline (engineer → qa → reviewer)
|
|
35
|
+
- The task needs to be tracked, claimed, and its status visible to everyone
|
|
36
|
+
|
|
37
|
+
Use **inbox message** (send_message) when:
|
|
38
|
+
- Asking a clarifying question ("what format should X be?")
|
|
39
|
+
- Quick status update ("I'm starting on this")
|
|
40
|
+
- Reporting a bug back to upstream (after review fails)
|
|
41
|
+
- Coordinating without a concrete deliverable
|
|
42
|
+
|
|
43
|
+
**Decision tree when user or another smith asks you to coordinate work:**
|
|
44
|
+
\`\`\`
|
|
45
|
+
Is it substantive implementation/testing/review work with clear acceptance criteria?
|
|
46
|
+
├─ YES → create_request (then notify via inbox if needed)
|
|
47
|
+
└─ NO → send_message only
|
|
48
|
+
|
|
49
|
+
Is it a question or quick coordination (no deliverable)?
|
|
50
|
+
├─ YES → send_message only
|
|
51
|
+
└─ NO → create_request
|
|
52
|
+
|
|
53
|
+
Am I being asked to do work that would result in code/tests/docs changes?
|
|
54
|
+
├─ YES and I'm executing it → claim_request if one exists, or tell user to create one
|
|
55
|
+
└─ NO → just respond via inbox
|
|
56
|
+
\`\`\`
|
|
57
|
+
|
|
58
|
+
**When unsure, prefer create_request** — having a tracked artifact beats losing context in chat.
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
|
|
25
62
|
export const AGENT_PRESETS: Record<string, PresetTemplate> = {
|
|
26
63
|
pm: {
|
|
27
64
|
label: 'PM',
|
|
@@ -162,7 +199,9 @@ IF request stuck open (no one claimed):
|
|
|
162
199
|
- Do NOT write code — your output is request documents only
|
|
163
200
|
- Each acceptance_criterion must be verifiable by QA
|
|
164
201
|
- Group related requests in a batch for tracking
|
|
165
|
-
- Downstream agents are auto-notified via DAG when you create requests
|
|
202
|
+
- Downstream agents are auto-notified via DAG when you create requests
|
|
203
|
+
|
|
204
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
166
205
|
backend: 'cli',
|
|
167
206
|
agentId: 'claude',
|
|
168
207
|
dependsOn: [],
|
|
@@ -241,7 +280,9 @@ IF you encounter a blocking issue (unclear requirement, impossible constraint):
|
|
|
241
280
|
- ALWAYS update_response when done — triggers downstream pipeline
|
|
242
281
|
- Only implement what the request asks — don't scope-creep
|
|
243
282
|
- Architecture docs are versioned — never overwrite
|
|
244
|
-
- Existing working code stays unless request explicitly requires changes
|
|
283
|
+
- Existing working code stays unless request explicitly requires changes
|
|
284
|
+
|
|
285
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
245
286
|
backend: 'cli',
|
|
246
287
|
agentId: 'claude',
|
|
247
288
|
dependsOn: [],
|
|
@@ -331,7 +372,9 @@ IF result = "failed":
|
|
|
331
372
|
- Do NOT fix bugs — only report them
|
|
332
373
|
- Each test must trace back to an acceptance_criterion
|
|
333
374
|
- One consolidated message max — never spam Engineers
|
|
334
|
-
- Never send messages during planning/writing — only after execution
|
|
375
|
+
- Never send messages during planning/writing — only after execution
|
|
376
|
+
|
|
377
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
335
378
|
backend: 'cli',
|
|
336
379
|
agentId: 'claude',
|
|
337
380
|
dependsOn: [],
|
|
@@ -436,7 +479,9 @@ IF result = "rejected":
|
|
|
436
479
|
- Review ONLY files_changed from the request, not the entire codebase
|
|
437
480
|
- Actionable feedback: not "this is bad" but "change X to Y because Z"
|
|
438
481
|
- One consolidated message max per verdict
|
|
439
|
-
- MINOR findings go in report only — never message about style
|
|
482
|
+
- MINOR findings go in report only — never message about style
|
|
483
|
+
|
|
484
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
440
485
|
backend: 'cli',
|
|
441
486
|
agentId: 'claude',
|
|
442
487
|
dependsOn: [],
|
|
@@ -549,7 +594,9 @@ IF multiple Engineers exist and request unclaimed:
|
|
|
549
594
|
- Every delegated task MUST go through request documents (create_request)
|
|
550
595
|
- Each request needs concrete acceptance_criteria that QA can verify
|
|
551
596
|
- Do NOT duplicate work an active agent is already doing — check status first
|
|
552
|
-
- When covering a gap, be thorough — don't half-do it just because it's not your "main" role
|
|
597
|
+
- When covering a gap, be thorough — don't half-do it just because it's not your "main" role
|
|
598
|
+
|
|
599
|
+
${REQUEST_VS_INBOX_RULE}`,
|
|
553
600
|
backend: 'cli',
|
|
554
601
|
agentId: 'claude',
|
|
555
602
|
dependsOn: [],
|
|
@@ -58,7 +58,11 @@ function loadOrchestrator(id: string): WorkspaceOrchestrator {
|
|
|
58
58
|
agentStates: state.agentStates,
|
|
59
59
|
busLog: state.busLog,
|
|
60
60
|
busOutbox: state.busOutbox,
|
|
61
|
+
nodePositions: state.nodePositions,
|
|
61
62
|
});
|
|
63
|
+
} else if (state.nodePositions) {
|
|
64
|
+
// Load positions even when no agents yet (rare, but safe)
|
|
65
|
+
orch.loadSnapshot({ agents: [], agentStates: {}, busLog: [], nodePositions: state.nodePositions });
|
|
62
66
|
}
|
|
63
67
|
|
|
64
68
|
// Wire up SSE broadcasting
|
|
@@ -190,6 +194,15 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
190
194
|
return jsonError(res, err.message);
|
|
191
195
|
}
|
|
192
196
|
}
|
|
197
|
+
case 'set_positions': {
|
|
198
|
+
const positions = body.positions as Record<string, { x: number; y: number }> | undefined;
|
|
199
|
+
if (!positions) return jsonError(res, 'positions required');
|
|
200
|
+
orch.setNodePositions(positions);
|
|
201
|
+
return json(res, { ok: true });
|
|
202
|
+
}
|
|
203
|
+
case 'get_positions': {
|
|
204
|
+
return json(res, { positions: orch.getNodePositions() });
|
|
205
|
+
}
|
|
193
206
|
case 'agent_done': {
|
|
194
207
|
// Called by Claude Code Stop hook — agent finished a turn
|
|
195
208
|
if (!agentId) return jsonError(res, 'agentId required');
|
package/package.json
CHANGED