@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 CHANGED
@@ -1,20 +1,8 @@
1
- # Forge v0.5.30
1
+ # Forge v0.5.32
2
2
 
3
3
  Released: 2026-04-10
4
4
 
5
- ## Changes since v0.5.29
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
- ### Bug Fixes
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="w-full 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)]"
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, setSidebarCollapsed] = useState(false);
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
- setTabs(prev => {
107
- const existing = prev.find(t => t.projectPath === p.path);
108
- if (existing) {
109
- // Activate existing tab
110
- const updated = prev.map(t => t.id === existing.id ? { ...t, mountedAt: Date.now() } : t);
111
- setActiveTabId(existing.id);
112
- persistTabs(updated, existing.id);
113
- return updated;
114
- }
115
- // Create new tab
116
- const newTab: ProjectTab = {
117
- id: genTabId(),
118
- projectPath: p.path,
119
- projectName: p.name,
120
- hasGit: p.hasGit,
121
- mountedAt: Date.now(),
122
- };
123
- const updated = [...prev, newTab];
124
- setActiveTabId(newTab.id);
125
- persistTabs(updated, newTab.id);
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
- setTabs(prev => {
133
- const updated = prev.map(t => t.id === id ? { ...t, mountedAt: Date.now() } : t);
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 project initials */}
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 isActive = activeTab?.projectPath === p.path;
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={`w-full py-1.5 text-[9px] font-bold text-center ${
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
- <button
290
- key={`fav-${p.path}`}
291
- onClick={() => openProjectTab(p)}
292
- className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
293
- tabs.find(t => t.id === activeTabId)?.projectPath === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
294
- }`}
295
- >
296
- <span
297
- onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
298
- className="text-[13px] text-[var(--yellow)] shrink-0 cursor-pointer leading-none"
299
- title="Remove from favorites"
300
- >★</span>
301
- <span className="truncate">{p.name}</span>
302
- {p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
303
- {p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
304
- </button>
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
- <button
325
- key={p.path}
326
- onClick={() => openProjectTab(p)}
327
- className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
328
- tabs.find(t => t.id === activeTabId)?.projectPath === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
329
- }`}
330
- >
331
- <span
332
- onClick={(e) => { e.stopPropagation(); toggleFavorite(p.path); }}
333
- 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)]'}`}
334
- title={favorites.includes(p.path) ? 'Remove from favorites' : 'Add to favorites'}
335
- >{favorites.includes(p.path) ? '' : ''}</span>
336
- <span className="truncate">{p.name}</span>
337
- {p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
338
- {p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
339
- </button>
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)] overflow-y-auto shrink-0">
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)] ml-auto">
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 onClick={() => setShowCloseDialog(true)} className="ml-auto text-gray-500 hover:text-white text-sm shrink-0">✕</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) {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.5.30",
3
+ "version": "0.5.32",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {