@aion0/forge 0.2.1 → 0.2.3

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.
@@ -0,0 +1,410 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+
5
+ interface Project {
6
+ name: string;
7
+ path: string;
8
+ root: string;
9
+ hasGit: boolean;
10
+ language: string | null;
11
+ }
12
+
13
+ interface GitInfo {
14
+ branch: string;
15
+ changes: { status: string; path: string }[];
16
+ remote: string;
17
+ ahead: number;
18
+ behind: number;
19
+ lastCommit: string;
20
+ log: { hash: string; message: string; author: string; date: string }[];
21
+ }
22
+
23
+ export default function ProjectManager() {
24
+ const [projects, setProjects] = useState<Project[]>([]);
25
+ const [selectedProject, setSelectedProject] = useState<Project | null>(null);
26
+ const [gitInfo, setGitInfo] = useState<GitInfo | null>(null);
27
+ const [loading, setLoading] = useState(false);
28
+ const [commitMsg, setCommitMsg] = useState('');
29
+ const [gitLoading, setGitLoading] = useState(false);
30
+ const [gitResult, setGitResult] = useState<{ ok?: boolean; error?: string } | null>(null);
31
+ const [showClone, setShowClone] = useState(false);
32
+ const [cloneUrl, setCloneUrl] = useState('');
33
+ const [cloneLoading, setCloneLoading] = useState(false);
34
+ const [fileTree, setFileTree] = useState<any[]>([]);
35
+ const [selectedFile, setSelectedFile] = useState<string | null>(null);
36
+ const [fileContent, setFileContent] = useState<string | null>(null);
37
+ const [fileLanguage, setFileLanguage] = useState('');
38
+ const [fileLoading, setFileLoading] = useState(false);
39
+ const [showLog, setShowLog] = useState(false);
40
+
41
+ // Fetch projects
42
+ useEffect(() => {
43
+ fetch('/api/projects').then(r => r.json())
44
+ .then((p: Project[]) => { if (Array.isArray(p)) setProjects(p); })
45
+ .catch(() => {});
46
+ }, []);
47
+
48
+ // Fetch git info when project selected
49
+ const fetchGitInfo = useCallback(async (project: Project) => {
50
+ if (!project.hasGit) { setGitInfo(null); return; }
51
+ setLoading(true);
52
+ try {
53
+ const res = await fetch(`/api/git?dir=${encodeURIComponent(project.path)}`);
54
+ const data = await res.json();
55
+ if (!data.error) setGitInfo(data);
56
+ else setGitInfo(null);
57
+ } catch { setGitInfo(null); }
58
+ setLoading(false);
59
+ }, []);
60
+
61
+ // Fetch file tree
62
+ const fetchTree = useCallback(async (project: Project) => {
63
+ try {
64
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(project.path)}`);
65
+ const data = await res.json();
66
+ setFileTree(data.tree || []);
67
+ } catch { setFileTree([]); }
68
+ }, []);
69
+
70
+ const selectProject = useCallback((p: Project) => {
71
+ setSelectedProject(p);
72
+ setSelectedFile(null);
73
+ setFileContent(null);
74
+ setGitResult(null);
75
+ setCommitMsg('');
76
+ fetchGitInfo(p);
77
+ fetchTree(p);
78
+ }, [fetchGitInfo, fetchTree]);
79
+
80
+ const openFile = useCallback(async (path: string) => {
81
+ if (!selectedProject) return;
82
+ setSelectedFile(path);
83
+ setFileLoading(true);
84
+ try {
85
+ const res = await fetch(`/api/code?dir=${encodeURIComponent(selectedProject.path)}&file=${encodeURIComponent(path)}`);
86
+ const data = await res.json();
87
+ setFileContent(data.content || null);
88
+ setFileLanguage(data.language || '');
89
+ } catch { setFileContent(null); }
90
+ setFileLoading(false);
91
+ }, [selectedProject]);
92
+
93
+ // Git operations
94
+ const gitAction = async (action: string, extra?: any) => {
95
+ if (!selectedProject) return;
96
+ setGitLoading(true);
97
+ setGitResult(null);
98
+ try {
99
+ const res = await fetch('/api/git', {
100
+ method: 'POST',
101
+ headers: { 'Content-Type': 'application/json' },
102
+ body: JSON.stringify({ action, dir: selectedProject.path, ...extra }),
103
+ });
104
+ const data = await res.json();
105
+ setGitResult(data);
106
+ if (data.ok) fetchGitInfo(selectedProject);
107
+ } catch (e: any) {
108
+ setGitResult({ error: e.message });
109
+ }
110
+ setGitLoading(false);
111
+ };
112
+
113
+ const handleClone = async () => {
114
+ if (!cloneUrl.trim()) return;
115
+ setCloneLoading(true);
116
+ setGitResult(null);
117
+ try {
118
+ const res = await fetch('/api/git', {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({ action: 'clone', repoUrl: cloneUrl.trim() }),
122
+ });
123
+ const data = await res.json();
124
+ if (data.ok) {
125
+ setCloneUrl('');
126
+ setShowClone(false);
127
+ // Refresh project list
128
+ const pRes = await fetch('/api/projects');
129
+ const pData = await pRes.json();
130
+ if (Array.isArray(pData)) setProjects(pData);
131
+ setGitResult({ ok: true });
132
+ } else {
133
+ setGitResult(data);
134
+ }
135
+ } catch (e: any) {
136
+ setGitResult({ error: e.message });
137
+ }
138
+ setCloneLoading(false);
139
+ };
140
+
141
+ // Group projects by root
142
+ const roots = [...new Set(projects.map(p => p.root))];
143
+
144
+ return (
145
+ <div className="flex-1 flex min-h-0">
146
+ {/* Left sidebar — project list */}
147
+ <aside className="w-64 border-r border-[var(--border)] flex flex-col shrink-0">
148
+ <div className="px-3 py-2 border-b border-[var(--border)] flex items-center justify-between">
149
+ <span className="text-[11px] font-semibold text-[var(--text-primary)]">Projects</span>
150
+ <button
151
+ onClick={() => setShowClone(v => !v)}
152
+ className={`text-[10px] px-2 py-0.5 rounded ${showClone ? 'text-white bg-[var(--accent)]' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
153
+ >
154
+ + Clone
155
+ </button>
156
+ </div>
157
+
158
+ {/* Clone form */}
159
+ {showClone && (
160
+ <div className="p-2 border-b border-[var(--border)] space-y-2">
161
+ <input
162
+ value={cloneUrl}
163
+ onChange={e => setCloneUrl(e.target.value)}
164
+ onKeyDown={e => e.key === 'Enter' && handleClone()}
165
+ placeholder="https://github.com/user/repo.git"
166
+ className="w-full text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
167
+ />
168
+ <button
169
+ onClick={handleClone}
170
+ disabled={cloneLoading || !cloneUrl.trim()}
171
+ className="w-full text-[10px] px-2 py-1 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50"
172
+ >
173
+ {cloneLoading ? 'Cloning...' : 'Clone Repository'}
174
+ </button>
175
+ </div>
176
+ )}
177
+
178
+ {/* Project list */}
179
+ <div className="flex-1 overflow-y-auto">
180
+ {roots.map(root => {
181
+ const rootName = root.split('/').pop() || root;
182
+ const rootProjects = projects.filter(p => p.root === root);
183
+ return (
184
+ <div key={root}>
185
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] uppercase bg-[var(--bg-tertiary)]">
186
+ {rootName}
187
+ </div>
188
+ {rootProjects.map(p => (
189
+ <button
190
+ key={p.path}
191
+ onClick={() => selectProject(p)}
192
+ className={`w-full text-left px-3 py-1.5 text-xs border-b border-[var(--border)]/30 flex items-center gap-2 ${
193
+ selectedProject?.path === p.path ? 'bg-[var(--accent)]/10 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-primary)]'
194
+ }`}
195
+ >
196
+ <span className="truncate">{p.name}</span>
197
+ {p.language && <span className="text-[8px] text-[var(--text-secondary)] ml-auto shrink-0">{p.language}</span>}
198
+ {p.hasGit && <span className="text-[8px] text-[var(--accent)] shrink-0">git</span>}
199
+ </button>
200
+ ))}
201
+ </div>
202
+ );
203
+ })}
204
+ </div>
205
+ </aside>
206
+
207
+ {/* Main area */}
208
+ <div className="flex-1 flex flex-col min-w-0">
209
+ {selectedProject ? (
210
+ <>
211
+ {/* Project header */}
212
+ <div className="px-4 py-2 border-b border-[var(--border)] shrink-0">
213
+ <div className="flex items-center gap-2">
214
+ <span className="text-sm font-semibold text-[var(--text-primary)]">{selectedProject.name}</span>
215
+ {gitInfo?.branch && (
216
+ <span className="text-[9px] text-[var(--accent)] bg-[var(--accent)]/10 px-1.5 py-0.5 rounded">{gitInfo.branch}</span>
217
+ )}
218
+ {gitInfo?.ahead ? <span className="text-[9px] text-green-400">↑{gitInfo.ahead}</span> : null}
219
+ {gitInfo?.behind ? <span className="text-[9px] text-yellow-400">↓{gitInfo.behind}</span> : null}
220
+ <button
221
+ onClick={() => { fetchGitInfo(selectedProject); fetchTree(selectedProject); if (selectedFile) openFile(selectedFile); }}
222
+ className="text-[10px] text-[var(--text-secondary)] hover:text-[var(--text-primary)] ml-auto"
223
+ title="Refresh"
224
+ >
225
+
226
+ </button>
227
+ </div>
228
+ <div className="text-[9px] text-[var(--text-secondary)] mt-0.5">
229
+ {selectedProject.path}
230
+ {gitInfo?.remote && (
231
+ <span className="ml-2">{gitInfo.remote.replace(/^https?:\/\//, '').replace(/^git@github\.com:/, 'github.com/').replace(/\.git$/, '')}</span>
232
+ )}
233
+ </div>
234
+ {gitInfo?.lastCommit && (
235
+ <div className="flex items-center gap-2 mt-0.5">
236
+ <span className="text-[9px] text-[var(--text-secondary)] font-mono truncate">{gitInfo.lastCommit}</span>
237
+ <button
238
+ onClick={() => setShowLog(v => !v)}
239
+ className={`text-[9px] px-1.5 py-0.5 rounded shrink-0 ${showLog ? 'text-white bg-[var(--accent)]/30' : 'text-[var(--accent)] hover:bg-[var(--accent)]/10'}`}
240
+ >
241
+ History
242
+ </button>
243
+ </div>
244
+ )}
245
+ </div>
246
+
247
+ {/* Git log */}
248
+ {showLog && gitInfo?.log && gitInfo.log.length > 0 && (
249
+ <div className="max-h-48 overflow-y-auto border-b border-[var(--border)] bg-[var(--bg-tertiary)]">
250
+ {gitInfo.log.map(c => (
251
+ <div key={c.hash} className="px-4 py-1.5 border-b border-[var(--border)]/30 text-xs flex items-start gap-2">
252
+ <span className="font-mono text-[var(--accent)] shrink-0 text-[10px]">{c.hash}</span>
253
+ <span className="text-[var(--text-primary)] truncate flex-1">{c.message}</span>
254
+ <span className="text-[var(--text-secondary)] text-[9px] shrink-0">{c.author}</span>
255
+ <span className="text-[var(--text-secondary)] text-[9px] shrink-0 w-16 text-right">{c.date}</span>
256
+ </div>
257
+ ))}
258
+ </div>
259
+ )}
260
+
261
+ {/* Content area */}
262
+ <div className="flex-1 flex min-h-0 overflow-hidden">
263
+ {/* File tree */}
264
+ <div className="w-52 border-r border-[var(--border)] overflow-y-auto p-1 shrink-0">
265
+ {fileTree.map((node: any) => (
266
+ <FileTreeNode key={node.path} node={node} depth={0} selected={selectedFile} onSelect={openFile} />
267
+ ))}
268
+ </div>
269
+
270
+ {/* File content — independent scroll */}
271
+ <div className="flex-1 min-w-0 overflow-auto bg-[var(--bg-primary)]">
272
+ {fileLoading ? (
273
+ <div className="h-full flex items-center justify-center text-xs text-[var(--text-secondary)]">Loading...</div>
274
+ ) : selectedFile && fileContent !== null ? (
275
+ <>
276
+ <div className="px-3 py-1 border-b border-[var(--border)] text-[10px] text-[var(--text-secondary)] sticky top-0 bg-[var(--bg-primary)] z-10">{selectedFile}</div>
277
+ <pre className="p-4 text-[12px] leading-[1.5] font-mono text-[var(--text-primary)] whitespace-pre" style={{ fontFamily: 'Menlo, Monaco, "Courier New", monospace', tabSize: 2 }}>
278
+ {fileContent.split('\n').map((line, i) => (
279
+ <div key={i} className="flex hover:bg-[var(--bg-tertiary)]/50">
280
+ <span className="select-none text-[var(--text-secondary)]/40 text-right pr-4 w-10 shrink-0">{i + 1}</span>
281
+ <span className="flex-1">{line || ' '}</span>
282
+ </div>
283
+ ))}
284
+ </pre>
285
+ </>
286
+ ) : (
287
+ <div className="flex-1 flex items-center justify-center text-xs text-[var(--text-secondary)]">
288
+ Select a file to view
289
+ </div>
290
+ )}
291
+ </div>
292
+ </div>
293
+
294
+ {/* Git panel — bottom */}
295
+ {gitInfo && (
296
+ <div className="border-t border-[var(--border)] shrink-0">
297
+ {/* Changes list */}
298
+ {gitInfo.changes.length > 0 && (
299
+ <div className="max-h-32 overflow-y-auto border-b border-[var(--border)]">
300
+ <div className="px-3 py-1 text-[9px] text-[var(--text-secondary)] bg-[var(--bg-tertiary)] sticky top-0">
301
+ {gitInfo.changes.length} changes
302
+ </div>
303
+ {gitInfo.changes.map(g => (
304
+ <div key={g.path} className="px-3 py-0.5 text-xs flex items-center gap-2">
305
+ <span className={`text-[10px] font-mono w-4 ${
306
+ g.status.includes('M') ? 'text-yellow-500' :
307
+ g.status.includes('?') ? 'text-green-500' :
308
+ g.status.includes('D') ? 'text-red-500' : 'text-[var(--text-secondary)]'
309
+ }`}>
310
+ {g.status.includes('?') ? '+' : g.status[0]}
311
+ </span>
312
+ <span className="text-[var(--text-secondary)] truncate">{g.path}</span>
313
+ </div>
314
+ ))}
315
+ </div>
316
+ )}
317
+
318
+ {/* Git actions */}
319
+ <div className="px-3 py-2 flex items-center gap-2">
320
+ <input
321
+ value={commitMsg}
322
+ onChange={e => setCommitMsg(e.target.value)}
323
+ onKeyDown={e => e.key === 'Enter' && commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
324
+ placeholder="Commit message..."
325
+ className="flex-1 text-xs bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1.5 text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
326
+ />
327
+ <button
328
+ onClick={() => commitMsg.trim() && gitAction('commit', { message: commitMsg.trim() })}
329
+ disabled={gitLoading || !commitMsg.trim() || gitInfo.changes.length === 0}
330
+ className="text-[10px] px-3 py-1.5 bg-[var(--accent)] text-white rounded hover:opacity-90 disabled:opacity-50 shrink-0"
331
+ >
332
+ Commit
333
+ </button>
334
+ <button
335
+ onClick={() => gitAction('push')}
336
+ disabled={gitLoading || gitInfo.ahead === 0}
337
+ className="text-[10px] px-3 py-1.5 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white disabled:opacity-50 shrink-0"
338
+ >
339
+ Push{gitInfo.ahead > 0 ? ` (${gitInfo.ahead})` : ''}
340
+ </button>
341
+ <button
342
+ onClick={() => gitAction('pull')}
343
+ disabled={gitLoading}
344
+ className="text-[10px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] shrink-0"
345
+ >
346
+ Pull{gitInfo.behind > 0 ? ` (${gitInfo.behind})` : ''}
347
+ </button>
348
+ </div>
349
+
350
+ {/* Result */}
351
+ {gitResult && (
352
+ <div className={`px-3 py-1 text-[10px] ${gitResult.ok ? 'text-green-400' : 'text-red-400'}`}>
353
+ {gitResult.ok ? '✅ Done' : `❌ ${gitResult.error}`}
354
+ </div>
355
+ )}
356
+ </div>
357
+ )}
358
+ </>
359
+ ) : (
360
+ <div className="flex-1 flex items-center justify-center text-[var(--text-secondary)]">
361
+ <div className="text-center space-y-2">
362
+ <p className="text-sm">Select a project</p>
363
+ <p className="text-xs">{projects.length} projects across {roots.length} directories</p>
364
+ </div>
365
+ </div>
366
+ )}
367
+ </div>
368
+ </div>
369
+ );
370
+ }
371
+
372
+ // Simple file tree node
373
+ function FileTreeNode({ node, depth, selected, onSelect }: {
374
+ node: { name: string; path: string; type: string; children?: any[] };
375
+ depth: number;
376
+ selected: string | null;
377
+ onSelect: (path: string) => void;
378
+ }) {
379
+ const [expanded, setExpanded] = useState(depth < 1);
380
+
381
+ if (node.type === 'dir') {
382
+ return (
383
+ <div>
384
+ <button
385
+ onClick={() => setExpanded(v => !v)}
386
+ className="w-full text-left flex items-center gap-1 px-1 py-0.5 hover:bg-[var(--bg-tertiary)] rounded text-xs"
387
+ style={{ paddingLeft: depth * 12 + 4 }}
388
+ >
389
+ <span className="text-[10px] text-[var(--text-secondary)] w-3">{expanded ? '▾' : '▸'}</span>
390
+ <span className="text-[var(--text-primary)]">{node.name}</span>
391
+ </button>
392
+ {expanded && node.children?.map((child: any) => (
393
+ <FileTreeNode key={child.path} node={child} depth={depth + 1} selected={selected} onSelect={onSelect} />
394
+ ))}
395
+ </div>
396
+ );
397
+ }
398
+
399
+ return (
400
+ <button
401
+ onClick={() => onSelect(node.path)}
402
+ className={`w-full text-left px-1 py-0.5 rounded text-xs truncate ${
403
+ selected === node.path ? 'bg-[var(--accent)]/20 text-[var(--accent)]' : 'hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)]'
404
+ }`}
405
+ style={{ paddingLeft: depth * 12 + 16 }}
406
+ >
407
+ {node.name}
408
+ </button>
409
+ );
410
+ }
@@ -216,9 +216,12 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
216
216
  <input
217
217
  value={settings.telegramChatId}
218
218
  onChange={e => setSettings({ ...settings, telegramChatId: e.target.value })}
219
- placeholder="Chat ID (your numeric user ID)"
219
+ placeholder="Chat ID (comma-separated for multiple)"
220
220
  className="w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]"
221
221
  />
222
+ <p className="text-[9px] text-[var(--text-secondary)]">
223
+ Allowed user IDs (whitelist). Multiple IDs separated by commas. Only these users can interact with the bot.
224
+ </p>
222
225
  <div className="flex items-center gap-4">
223
226
  <label className="flex items-center gap-1.5 text-[11px] text-[var(--text-secondary)]">
224
227
  <input
@@ -231,7 +231,7 @@ export default function TaskDetail({
231
231
 
232
232
  {editing && (
233
233
  <NewTaskModal
234
- editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode }}
234
+ editTask={{ id: task.id, projectName: task.projectName, prompt: task.prompt, priority: task.priority, mode: task.mode, scheduledAt: task.scheduledAt }}
235
235
  onClose={() => setEditing(false)}
236
236
  onCreate={async (data) => {
237
237
  await fetch(`/api/tasks/${task.id}`, {
@@ -26,7 +26,7 @@ interface TmuxSession {
26
26
  }
27
27
 
28
28
  type SplitNode =
29
- | { type: 'terminal'; id: number; sessionName?: string }
29
+ | { type: 'terminal'; id: number; sessionName?: string; projectPath?: string }
30
30
  | { type: 'split'; id: number; direction: 'horizontal' | 'vertical'; ratio: number; first: SplitNode; second: SplitNode };
31
31
 
32
32
  interface TabState {
@@ -35,6 +35,7 @@ interface TabState {
35
35
  tree: SplitNode;
36
36
  ratios: Record<number, number>;
37
37
  activeId: number;
38
+ projectPath?: string; // If set, auto-run claude --resume in this dir on session create
38
39
  }
39
40
 
40
41
  // ─── Layout persistence ──────────────────────────────────────
@@ -99,8 +100,8 @@ function initNextIdFromTabs(tabs: TabState[]) {
99
100
  }
100
101
  }
101
102
 
102
- function makeTerminal(sessionName?: string): SplitNode {
103
- return { type: 'terminal', id: nextId++, sessionName };
103
+ function makeTerminal(sessionName?: string, projectPath?: string): SplitNode {
104
+ return { type: 'terminal', id: nextId++, sessionName, projectPath };
104
105
  }
105
106
 
106
107
  function makeSplit(direction: 'horizontal' | 'vertical', first: SplitNode, second: SplitNode): SplitNode {
@@ -178,6 +179,10 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
178
179
  const dragTabRef = useRef<number | null>(null);
179
180
  const [refreshKeys, setRefreshKeys] = useState<Record<number, number>>({});
180
181
  const [tabCodeOpen, setTabCodeOpen] = useState<Record<number, boolean>>({});
182
+ const [showNewTabModal, setShowNewTabModal] = useState(false);
183
+ const [projectRoots, setProjectRoots] = useState<string[]>([]);
184
+ const [allProjects, setAllProjects] = useState<{ name: string; path: string; root: string }[]>([]);
185
+ const [expandedRoot, setExpandedRoot] = useState<string | null>(null);
181
186
 
182
187
  // Restore shared state from server after mount
183
188
  useEffect(() => {
@@ -191,6 +196,15 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
191
196
  }
192
197
  setHydrated(true);
193
198
  });
199
+ // Fetch projects and derive roots
200
+ fetch('/api/projects').then(r => r.json())
201
+ .then((p: { name: string; path: string; root: string }[]) => {
202
+ if (!Array.isArray(p)) return;
203
+ setAllProjects(p);
204
+ const roots = [...new Set(p.map(proj => proj.root))];
205
+ setProjectRoots(roots);
206
+ })
207
+ .catch(() => {});
194
208
  }, []);
195
209
 
196
210
  // Persist to server on changes (debounced, only after hydration)
@@ -248,10 +262,11 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
248
262
 
249
263
  // ─── Tab operations ───────────────────────────────────
250
264
 
251
- const addTab = useCallback(() => {
252
- const tree = makeTerminal();
265
+ const addTab = useCallback((projectPath?: string) => {
266
+ const tree = makeTerminal(undefined, projectPath);
253
267
  const tabNum = tabs.length + 1;
254
- const newTab: TabState = { id: nextId++, label: `Terminal ${tabNum}`, tree, ratios: {}, activeId: firstTerminalId(tree) };
268
+ const label = projectPath ? projectPath.split('/').pop() || `Terminal ${tabNum}` : `Terminal ${tabNum}`;
269
+ const newTab: TabState = { id: nextId++, label, tree, ratios: {}, activeId: firstTerminalId(tree), projectPath };
255
270
  setTabs(prev => [...prev, newTab]);
256
271
  setActiveTabId(newTab.id);
257
272
  }, [tabs.length]);
@@ -486,9 +501,9 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
486
501
  </div>
487
502
  ))}
488
503
  <button
489
- onClick={addTab}
504
+ onClick={() => setShowNewTabModal(true)}
490
505
  className="px-2 py-1 text-[11px] text-gray-500 hover:text-white hover:bg-[#2a2a4a]"
491
- title="New terminal tab"
506
+ title="New tab"
492
507
  >
493
508
  +
494
509
  </button>
@@ -631,6 +646,75 @@ const WebTerminal = forwardRef<WebTerminalHandle, WebTerminalProps>(function Web
631
646
  </div>
632
647
  )}
633
648
 
649
+ {/* New tab modal */}
650
+ {showNewTabModal && (
651
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}>
652
+ <div className="bg-[#1a1a2e] border border-[#2a2a4a] rounded-lg shadow-xl w-[350px] max-h-[70vh] flex flex-col" onClick={e => e.stopPropagation()}>
653
+ <div className="px-4 py-3 border-b border-[#2a2a4a]">
654
+ <h3 className="text-sm font-semibold text-white">New Tab</h3>
655
+ </div>
656
+ <div className="flex-1 overflow-y-auto p-2">
657
+ {/* Plain terminal */}
658
+ <button
659
+ onClick={() => { addTab(); setShowNewTabModal(false); setExpandedRoot(null); }}
660
+ className="w-full text-left px-3 py-2 rounded hover:bg-[#2a2a4a] text-[12px] text-gray-300 flex items-center gap-2"
661
+ >
662
+ <span className="text-gray-500">▸</span> Terminal
663
+ </button>
664
+
665
+ {/* Project roots */}
666
+ {projectRoots.length > 0 && (
667
+ <div className="mt-2 pt-2 border-t border-[#2a2a4a]">
668
+ <div className="px-3 py-1 text-[9px] text-gray-500 uppercase">Claude in Project</div>
669
+ {projectRoots.map(root => {
670
+ const rootName = root.split('/').pop() || root;
671
+ const isExpanded = expandedRoot === root;
672
+ const rootProjects = allProjects.filter(p => p.root === root);
673
+ return (
674
+ <div key={root}>
675
+ <button
676
+ onClick={() => setExpandedRoot(isExpanded ? null : root)}
677
+ className="w-full text-left px-3 py-2 rounded hover:bg-[#2a2a4a] text-[12px] text-gray-300 flex items-center gap-2"
678
+ >
679
+ <span className="text-gray-500 text-[10px] w-3">{isExpanded ? '▾' : '▸'}</span>
680
+ <span>{rootName}</span>
681
+ <span className="text-[9px] text-gray-600 ml-auto">{rootProjects.length}</span>
682
+ </button>
683
+ {isExpanded && (
684
+ <div className="ml-4">
685
+ {rootProjects.map(p => (
686
+ <button
687
+ key={p.path}
688
+ onClick={() => { addTab(p.path); setShowNewTabModal(false); setExpandedRoot(null); }}
689
+ className="w-full text-left px-3 py-1.5 rounded hover:bg-[#2a2a4a] text-[11px] text-gray-300 flex items-center gap-2 truncate"
690
+ title={p.path}
691
+ >
692
+ <span className="text-gray-600 text-[10px]">↳</span> {p.name}
693
+ </button>
694
+ ))}
695
+ {rootProjects.length === 0 && (
696
+ <div className="px-3 py-1.5 text-[10px] text-gray-600">No projects</div>
697
+ )}
698
+ </div>
699
+ )}
700
+ </div>
701
+ );
702
+ })}
703
+ </div>
704
+ )}
705
+ </div>
706
+ <div className="px-4 py-2 border-t border-[#2a2a4a]">
707
+ <button
708
+ onClick={() => { setShowNewTabModal(false); setExpandedRoot(null); }}
709
+ className="w-full text-center text-[11px] text-gray-500 hover:text-gray-300 py-1"
710
+ >
711
+ Cancel
712
+ </button>
713
+ </div>
714
+ </div>
715
+ </div>
716
+ )}
717
+
634
718
  {/* Close confirmation dialog */}
635
719
  {closeConfirm && (
636
720
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setCloseConfirm(null)}>
@@ -706,7 +790,7 @@ function PaneRenderer({
706
790
  if (node.type === 'terminal') {
707
791
  return (
708
792
  <div className={`h-full w-full ${activeId === node.id ? 'ring-1 ring-[#7c5bf0]/50 ring-inset' : ''}`} onMouseDown={() => onFocus(node.id)}>
709
- <MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} onSessionConnected={onSessionConnected} />
793
+ <MemoTerminalPane key={`${node.id}-${refreshKeys[node.id] || 0}`} id={node.id} sessionName={node.sessionName} projectPath={node.projectPath} onSessionConnected={onSessionConnected} />
710
794
  </div>
711
795
  );
712
796
  }
@@ -836,15 +920,19 @@ function DraggableSplit({
836
920
  const MemoTerminalPane = memo(function TerminalPane({
837
921
  id,
838
922
  sessionName,
923
+ projectPath,
839
924
  onSessionConnected,
840
925
  }: {
841
926
  id: number;
842
927
  sessionName?: string;
928
+ projectPath?: string;
843
929
  onSessionConnected: (paneId: number, sessionName: string) => void;
844
930
  }) {
845
931
  const containerRef = useRef<HTMLDivElement>(null);
846
932
  const sessionNameRef = useRef(sessionName);
847
933
  sessionNameRef.current = sessionName;
934
+ const projectPathRef = useRef(projectPath);
935
+ projectPathRef.current = projectPath;
848
936
 
849
937
  useEffect(() => {
850
938
  if (!containerRef.current) return;
@@ -921,6 +1009,7 @@ const MemoTerminalPane = memo(function TerminalPane({
921
1009
  let createRetries = 0;
922
1010
  const MAX_CREATE_RETRIES = 2;
923
1011
  let reconnectAttempts = 0;
1012
+ let isNewlyCreated = false;
924
1013
 
925
1014
  function connect() {
926
1015
  if (disposed) return;
@@ -942,6 +1031,7 @@ const MemoTerminalPane = memo(function TerminalPane({
942
1031
  socket.send(JSON.stringify({ type: 'attach', sessionName: sn, cols, rows }));
943
1032
  } else if (createRetries < MAX_CREATE_RETRIES) {
944
1033
  createRetries++;
1034
+ isNewlyCreated = true;
945
1035
  socket.send(JSON.stringify({ type: 'create', cols, rows }));
946
1036
  } else {
947
1037
  term.write('\r\n\x1b[91m[failed to create session — check server logs]\x1b[0m\r\n');
@@ -960,6 +1050,16 @@ const MemoTerminalPane = memo(function TerminalPane({
960
1050
  createRetries = 0;
961
1051
  reconnectAttempts = 0;
962
1052
  onSessionConnected(id, msg.sessionName);
1053
+ // Auto-run claude --resume for project tabs on new session
1054
+ if (isNewlyCreated && projectPathRef.current) {
1055
+ isNewlyCreated = false;
1056
+ setTimeout(() => {
1057
+ if (!disposed && ws?.readyState === WebSocket.OPEN) {
1058
+ ws.send(JSON.stringify({ type: 'input', data: `cd "${projectPathRef.current}" && claude --resume\n` }));
1059
+ }
1060
+ }, 300);
1061
+ }
1062
+ isNewlyCreated = false;
963
1063
  // Force tmux to redraw by toggling size, then send reset
964
1064
  setTimeout(() => {
965
1065
  if (disposed || ws?.readyState !== WebSocket.OPEN) return;