hjworktree-cli 2.0.0 → 2.1.0

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.
@@ -7,8 +7,8 @@
7
7
  <link rel="preconnect" href="https://fonts.googleapis.com">
8
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
- <script type="module" crossorigin src="/assets/index-WEdVUKxb.js"></script>
11
- <link rel="stylesheet" crossorigin href="/assets/index-C61yAbey.css">
10
+ <script type="module" crossorigin src="/assets/index-D8dr9mJa.js"></script>
11
+ <link rel="stylesheet" crossorigin href="/assets/index-CsixHL-D.css">
12
12
  </head>
13
13
  <body>
14
14
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hjworktree-cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Web-based git worktree parallel AI coding agent runner",
5
5
  "type": "module",
6
6
  "main": "dist/server/index.js",
@@ -1,7 +1,7 @@
1
1
  import { Router, Request, Response } from 'express';
2
2
  import { GitService } from '../services/gitService.js';
3
3
  import { WorktreeService } from '../services/worktreeService.js';
4
- import type { CreateWorktreesRequest } from '../../shared/types/index.js';
4
+ import type { CreateWorktreesRequest, CreateSingleWorktreeRequest, BatchDeleteRequest } from '../../shared/types/index.js';
5
5
 
6
6
  export function apiRouter(cwd: string): Router {
7
7
  const router = Router();
@@ -52,7 +52,7 @@ export function apiRouter(cwd: string): Router {
52
52
  }
53
53
  });
54
54
 
55
- // Create worktrees
55
+ // Create worktrees (multiple)
56
56
  router.post('/worktrees', async (req: Request, res: Response) => {
57
57
  try {
58
58
  const { branch, count } = req.body as CreateWorktreesRequest;
@@ -71,6 +71,48 @@ export function apiRouter(cwd: string): Router {
71
71
  }
72
72
  });
73
73
 
74
+ // Create single worktree
75
+ router.post('/worktrees/single', async (req: Request, res: Response) => {
76
+ try {
77
+ const { branch } = req.body as CreateSingleWorktreeRequest;
78
+
79
+ if (!branch) {
80
+ res.status(400).json({ error: 'Invalid request: branch is required' });
81
+ return;
82
+ }
83
+
84
+ const worktree = await worktreeService.createSingleWorktree(branch);
85
+ res.json({ worktree });
86
+ } catch (error) {
87
+ res.status(500).json({
88
+ error: error instanceof Error ? error.message : 'Unknown error'
89
+ });
90
+ }
91
+ });
92
+
93
+ // Batch delete worktrees
94
+ router.delete('/worktrees/batch', async (req: Request, res: Response) => {
95
+ try {
96
+ const { names } = req.body as BatchDeleteRequest;
97
+
98
+ if (!names || !Array.isArray(names) || names.length === 0) {
99
+ res.status(400).json({ error: 'Invalid request: names array is required' });
100
+ return;
101
+ }
102
+
103
+ const result = await worktreeService.removeWorktreesByNames(names);
104
+ res.json({
105
+ success: result.failed.length === 0,
106
+ deleted: result.deleted,
107
+ failed: result.failed,
108
+ });
109
+ } catch (error) {
110
+ res.status(500).json({
111
+ error: error instanceof Error ? error.message : 'Unknown error'
112
+ });
113
+ }
114
+ });
115
+
74
116
  // Delete a worktree
75
117
  router.delete('/worktrees/:name', async (req: Request, res: Response) => {
76
118
  try {
@@ -149,6 +149,58 @@ export class WorktreeService {
149
149
  }
150
150
  }
151
151
 
152
+ async createSingleWorktree(baseBranch: string): Promise<Worktree> {
153
+ // Find next available index for this branch
154
+ const existingWorktrees = await this.listWorktrees();
155
+ const branchWorktrees = existingWorktrees.filter(
156
+ wt => wt.name.startsWith(`${baseBranch}-project-`)
157
+ );
158
+
159
+ // Find the highest index used
160
+ let maxIndex = 0;
161
+ for (const wt of branchWorktrees) {
162
+ const match = wt.name.match(/-project-(\d+)$/);
163
+ if (match) {
164
+ const index = parseInt(match[1], 10);
165
+ if (index > maxIndex) maxIndex = index;
166
+ }
167
+ }
168
+
169
+ const nextIndex = maxIndex + 1;
170
+ return this.createWorktree(baseBranch, nextIndex);
171
+ }
172
+
173
+ async removeWorktreesByNames(names: string[]): Promise<{
174
+ deleted: string[];
175
+ failed: { name: string; error: string }[];
176
+ }> {
177
+ const worktrees = await this.listWorktrees();
178
+ const results: { deleted: string[]; failed: { name: string; error: string }[] } = {
179
+ deleted: [],
180
+ failed: [],
181
+ };
182
+
183
+ for (const name of names) {
184
+ const worktree = worktrees.find(wt => wt.name === name);
185
+ if (!worktree) {
186
+ results.failed.push({ name, error: 'Worktree not found' });
187
+ continue;
188
+ }
189
+
190
+ try {
191
+ await this.removeWorktree(worktree.path);
192
+ results.deleted.push(name);
193
+ } catch (error) {
194
+ results.failed.push({
195
+ name,
196
+ error: error instanceof Error ? error.message : 'Unknown error',
197
+ });
198
+ }
199
+ }
200
+
201
+ return results;
202
+ }
203
+
152
204
  async cleanup(): Promise<void> {
153
205
  // Remove all worktrees created in this session
154
206
  for (const worktreePath of this.createdWorktrees) {
@@ -90,3 +90,47 @@ export interface CreateWorktreesRequest {
90
90
  export interface CreateWorktreesResponse {
91
91
  worktrees: Worktree[];
92
92
  }
93
+
94
+ // Single worktree creation
95
+ export interface CreateSingleWorktreeRequest {
96
+ branch: string;
97
+ agentType: AgentId;
98
+ }
99
+
100
+ export interface CreateSingleWorktreeResponse {
101
+ worktree: Worktree;
102
+ }
103
+
104
+ // Batch delete
105
+ export interface BatchDeleteRequest {
106
+ names: string[];
107
+ }
108
+
109
+ export interface BatchDeleteResponse {
110
+ success: boolean;
111
+ deleted: string[];
112
+ failed: { name: string; error: string }[];
113
+ }
114
+
115
+ // Session management types
116
+ export interface WorktreeSession extends TerminalInfo {
117
+ id: string;
118
+ createdAt: number;
119
+ }
120
+
121
+ export interface SessionGroup {
122
+ branchName: string;
123
+ sessions: WorktreeSession[];
124
+ isCollapsed: boolean;
125
+ }
126
+
127
+ // Modal types
128
+ export type ModalType = 'addWorktree' | 'confirmDelete' | null;
129
+
130
+ export interface ModalState {
131
+ type: ModalType;
132
+ data?: {
133
+ sessionIds?: string[];
134
+ branchName?: string;
135
+ };
136
+ }
package/web/src/App.tsx CHANGED
@@ -7,6 +7,7 @@ import BranchStep from './components/Steps/BranchStep.js';
7
7
  import AgentStep from './components/Steps/AgentStep.js';
8
8
  import WorktreeStep from './components/Steps/WorktreeStep.js';
9
9
  import TerminalPanel from './components/Terminal/TerminalPanel.js';
10
+ import ModalContainer from './components/Modals/ModalContainer.js';
10
11
 
11
12
  function App() {
12
13
  const { step, isLoading, loadingMessage, error } = useAppStore();
@@ -58,6 +59,8 @@ function App() {
58
59
  <span className={`dot ${connected ? 'connected' : 'disconnected'}`} />
59
60
  {connected ? 'Connected' : 'Disconnected'}
60
61
  </div>
62
+
63
+ <ModalContainer />
61
64
  </div>
62
65
  );
63
66
  }
@@ -1,6 +1,14 @@
1
1
  import React from 'react';
2
2
  import { useAppStore } from '../../stores/useAppStore.js';
3
- import type { NavigationStep, StepStatus } from '../../../../shared/types/index.js';
3
+ import type { NavigationStep, StepStatus, AgentStatus } from '../../../../shared/types/index.js';
4
+ import { AI_AGENTS } from '../../../../shared/constants.js';
5
+
6
+ // Step configuration
7
+ const STEP_CONFIG = [
8
+ { id: 'branch' as const, number: 1, label: 'Branch' },
9
+ { id: 'agent' as const, number: 2, label: 'Agent' },
10
+ { id: 'worktree' as const, number: 3, label: 'Count' },
11
+ ];
4
12
 
5
13
  interface StepItemProps {
6
14
  step: NavigationStep;
@@ -11,13 +19,6 @@ interface StepItemProps {
11
19
  disabled: boolean;
12
20
  }
13
21
 
14
- const STEP_CONFIG = [
15
- { id: 'branch' as const, number: 1, label: 'Branch Selection' },
16
- { id: 'agent' as const, number: 2, label: 'Agent Selection' },
17
- { id: 'worktree' as const, number: 3, label: 'Worktree Count' },
18
- { id: 'running' as const, number: 4, label: 'Running' },
19
- ];
20
-
21
22
  function StepItem({ number, label, status, onClick, disabled }: StepItemProps) {
22
23
  return (
23
24
  <button
@@ -34,32 +35,295 @@ function StepItem({ number, label, status, onClick, disabled }: StepItemProps) {
34
35
  );
35
36
  }
36
37
 
37
- function LeftNavBar() {
38
- const { step, goToStep, canNavigateTo, getStepStatus } = useAppStore();
38
+ // Setup Section Component
39
+ function SetupSection() {
40
+ const {
41
+ step,
42
+ goToStep,
43
+ canNavigateTo,
44
+ getStepStatus,
45
+ isSetupCollapsed,
46
+ toggleSetupCollapse,
47
+ terminals,
48
+ } = useAppStore();
49
+
50
+ const hasRunningTerminals = terminals.length > 0;
51
+
52
+ return (
53
+ <div className="lnb-section setup-section">
54
+ <div
55
+ className="lnb-section-header"
56
+ onClick={toggleSetupCollapse}
57
+ role="button"
58
+ tabIndex={0}
59
+ >
60
+ <span className="section-title">SETUP</span>
61
+ <span className="collapse-icon">{isSetupCollapsed ? '+' : '−'}</span>
62
+ </div>
63
+ {!isSetupCollapsed && (
64
+ <div className="lnb-steps">
65
+ {STEP_CONFIG.map((config) => (
66
+ <StepItem
67
+ key={config.id}
68
+ step={config.id}
69
+ number={config.number}
70
+ label={config.label}
71
+ status={getStepStatus(config.id)}
72
+ onClick={() => goToStep(config.id)}
73
+ disabled={!canNavigateTo(config.id)}
74
+ />
75
+ ))}
76
+ {hasRunningTerminals && (
77
+ <button
78
+ className={`lnb-step ${step === 'running' ? 'current' : 'completed'}`}
79
+ onClick={() => goToStep('running')}
80
+ type="button"
81
+ >
82
+ <span className="step-number">4</span>
83
+ <span className="step-label">Running</span>
84
+ </button>
85
+ )}
86
+ </div>
87
+ )}
88
+ </div>
89
+ );
90
+ }
91
+
92
+ // Status indicator component
93
+ function StatusIndicator({ status }: { status: AgentStatus }) {
94
+ const statusColors: Record<AgentStatus, string> = {
95
+ initializing: '#f59e0b',
96
+ installing: '#f59e0b',
97
+ running: '#22c55e',
98
+ stopped: '#6b7280',
99
+ error: '#ef4444',
100
+ };
101
+
102
+ return (
103
+ <span
104
+ className="status-indicator"
105
+ style={{ backgroundColor: statusColors[status] }}
106
+ title={status}
107
+ />
108
+ );
109
+ }
110
+
111
+ // Session Item Component
112
+ interface SessionItemProps {
113
+ sessionId: string;
114
+ worktreeName: string;
115
+ agentType: string;
116
+ status: AgentStatus;
117
+ isActive: boolean;
118
+ isSelected: boolean;
119
+ onSelect: () => void;
120
+ onToggleSelect: (e: React.MouseEvent) => void;
121
+ onClose: (e: React.MouseEvent) => void;
122
+ }
123
+
124
+ function SessionItem({
125
+ sessionId,
126
+ worktreeName,
127
+ agentType,
128
+ status,
129
+ isActive,
130
+ isSelected,
131
+ onSelect,
132
+ onToggleSelect,
133
+ onClose,
134
+ }: SessionItemProps) {
135
+ const agent = AI_AGENTS.find(a => a.id === agentType);
136
+
137
+ return (
138
+ <div
139
+ className={`session-item ${isActive ? 'active' : ''} ${isSelected ? 'selected' : ''}`}
140
+ onClick={onSelect}
141
+ role="button"
142
+ tabIndex={0}
143
+ >
144
+ <input
145
+ type="checkbox"
146
+ className="session-checkbox"
147
+ checked={isSelected}
148
+ onClick={onToggleSelect}
149
+ onChange={() => {}}
150
+ />
151
+ <StatusIndicator status={status} />
152
+ <span className="session-name" title={worktreeName}>
153
+ {worktreeName}
154
+ </span>
155
+ <span className="session-agent" title={agent?.name}>
156
+ {agentType.charAt(0).toUpperCase()}
157
+ </span>
158
+ <button
159
+ className="session-close"
160
+ onClick={onClose}
161
+ title="Close session"
162
+ type="button"
163
+ >
164
+ ×
165
+ </button>
166
+ </div>
167
+ );
168
+ }
39
169
 
40
- // Don't show LNB on running step
41
- if (step === 'running') {
170
+ // Session Group Component
171
+ interface SessionGroupProps {
172
+ branchName: string;
173
+ sessionCount: number;
174
+ isCollapsed: boolean;
175
+ onToggleCollapse: () => void;
176
+ children: React.ReactNode;
177
+ }
178
+
179
+ function SessionGroup({
180
+ branchName,
181
+ sessionCount,
182
+ isCollapsed,
183
+ onToggleCollapse,
184
+ children,
185
+ }: SessionGroupProps) {
186
+ return (
187
+ <div className="session-group">
188
+ <div
189
+ className="session-group-header"
190
+ onClick={onToggleCollapse}
191
+ role="button"
192
+ tabIndex={0}
193
+ >
194
+ <span className="group-collapse-icon">{isCollapsed ? '▶' : '▼'}</span>
195
+ <span className="group-name">{branchName}</span>
196
+ <span className="group-count">({sessionCount})</span>
197
+ </div>
198
+ {!isCollapsed && <div className="session-group-items">{children}</div>}
199
+ </div>
200
+ );
201
+ }
202
+
203
+ // Sessions Section Component
204
+ function SessionsSection() {
205
+ const {
206
+ terminals,
207
+ activeSessionId,
208
+ selectedSessionIds,
209
+ getSessionGroups,
210
+ setActiveSession,
211
+ toggleSessionSelection,
212
+ toggleBranchCollapse,
213
+ removeTerminal,
214
+ openModal,
215
+ } = useAppStore();
216
+
217
+ const sessionGroups = getSessionGroups();
218
+
219
+ const handleCloseSession = async (e: React.MouseEvent, sessionId: string, worktreeName: string) => {
220
+ e.stopPropagation();
221
+
222
+ try {
223
+ const response = await fetch(`/api/worktrees/${encodeURIComponent(worktreeName)}`, {
224
+ method: 'DELETE',
225
+ });
226
+
227
+ if (response.ok) {
228
+ removeTerminal(sessionId);
229
+ }
230
+ } catch (error) {
231
+ console.error('Failed to close session:', error);
232
+ }
233
+ };
234
+
235
+ if (terminals.length === 0) {
42
236
  return null;
43
237
  }
44
238
 
45
239
  return (
46
- <nav className="left-nav-bar">
47
- <div className="lnb-header">
48
- <span>Setup Steps</span>
240
+ <div className="lnb-section sessions-section">
241
+ <div className="lnb-section-header">
242
+ <span className="section-title">SESSIONS</span>
243
+ <span className="session-count">{terminals.length}</span>
49
244
  </div>
50
- <div className="lnb-steps">
51
- {STEP_CONFIG.map((config) => (
52
- <StepItem
53
- key={config.id}
54
- step={config.id}
55
- number={config.number}
56
- label={config.label}
57
- status={getStepStatus(config.id)}
58
- onClick={() => goToStep(config.id)}
59
- disabled={!canNavigateTo(config.id)}
60
- />
245
+ <div className="session-list">
246
+ {sessionGroups.map((group) => (
247
+ <SessionGroup
248
+ key={group.branchName}
249
+ branchName={group.branchName}
250
+ sessionCount={group.sessions.length}
251
+ isCollapsed={group.isCollapsed}
252
+ onToggleCollapse={() => toggleBranchCollapse(group.branchName)}
253
+ >
254
+ {group.sessions.map((session) => (
255
+ <SessionItem
256
+ key={session.sessionId}
257
+ sessionId={session.sessionId}
258
+ worktreeName={session.worktreeName}
259
+ agentType={session.agentType}
260
+ status={session.status}
261
+ isActive={session.sessionId === activeSessionId}
262
+ isSelected={selectedSessionIds.includes(session.sessionId)}
263
+ onSelect={() => setActiveSession(session.sessionId)}
264
+ onToggleSelect={(e) => {
265
+ e.stopPropagation();
266
+ toggleSessionSelection(session.sessionId);
267
+ }}
268
+ onClose={(e) => handleCloseSession(e, session.sessionId, session.worktreeName)}
269
+ />
270
+ ))}
271
+ </SessionGroup>
61
272
  ))}
62
273
  </div>
274
+ <button
275
+ className="add-session-btn"
276
+ onClick={() => openModal('addWorktree')}
277
+ type="button"
278
+ >
279
+ + Add Session
280
+ </button>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ // Bulk Actions Bar Component
286
+ function BulkActionsBar() {
287
+ const {
288
+ selectedSessionIds,
289
+ clearSessionSelection,
290
+ openModal,
291
+ } = useAppStore();
292
+
293
+ if (selectedSessionIds.length === 0) {
294
+ return null;
295
+ }
296
+
297
+ return (
298
+ <div className="bulk-actions-bar">
299
+ <span className="selected-count">
300
+ {selectedSessionIds.length} selected
301
+ </span>
302
+ <button
303
+ className="bulk-delete-btn"
304
+ onClick={() => openModal('confirmDelete', { sessionIds: selectedSessionIds })}
305
+ type="button"
306
+ >
307
+ Delete
308
+ </button>
309
+ <button
310
+ className="bulk-cancel-btn"
311
+ onClick={clearSessionSelection}
312
+ type="button"
313
+ >
314
+ Cancel
315
+ </button>
316
+ </div>
317
+ );
318
+ }
319
+
320
+ // Main LeftNavBar Component
321
+ function LeftNavBar() {
322
+ return (
323
+ <nav className="left-nav-bar">
324
+ <SetupSection />
325
+ <SessionsSection />
326
+ <BulkActionsBar />
63
327
  </nav>
64
328
  );
65
329
  }
@@ -1,19 +1,15 @@
1
1
  import React from 'react';
2
2
  import LeftNavBar from './LeftNavBar.js';
3
- import { useAppStore } from '../../stores/useAppStore.js';
4
3
 
5
4
  interface MainLayoutProps {
6
5
  children: React.ReactNode;
7
6
  }
8
7
 
9
8
  function MainLayout({ children }: MainLayoutProps) {
10
- const step = useAppStore((state) => state.step);
11
- const showLNB = step !== 'running';
12
-
13
9
  return (
14
10
  <div className="main-layout">
15
- {showLNB && <LeftNavBar />}
16
- <div className={`main-content-area ${showLNB ? 'with-lnb' : 'full-width'}`}>
11
+ <LeftNavBar />
12
+ <div className="main-content-area with-lnb">
17
13
  {children}
18
14
  </div>
19
15
  </div>
@@ -0,0 +1,87 @@
1
+ import React, { useState } from 'react';
2
+ import { useAppStore } from '../../stores/useAppStore.js';
3
+ import { AI_AGENTS } from '../../../../shared/constants.js';
4
+ import type { AgentId } from '../../../../shared/types/index.js';
5
+
6
+ function AddWorktreeModal() {
7
+ const { branches, closeModal, createSingleWorktree, isLoading } = useAppStore();
8
+ const [selectedBranch, setSelectedBranch] = useState<string>('');
9
+ const [selectedAgent, setSelectedAgent] = useState<AgentId>('claude');
10
+
11
+ const handleCreate = async () => {
12
+ if (!selectedBranch) return;
13
+ await createSingleWorktree(selectedBranch, selectedAgent);
14
+ };
15
+
16
+ return (
17
+ <div className="modal-overlay" onClick={closeModal}>
18
+ <div className="modal-content" onClick={(e) => e.stopPropagation()}>
19
+ <div className="modal-header">
20
+ <h2>Add New Session</h2>
21
+ <button className="modal-close" onClick={closeModal} type="button">
22
+ &times;
23
+ </button>
24
+ </div>
25
+
26
+ <div className="modal-body">
27
+ <div className="form-group">
28
+ <label htmlFor="branch-select">Branch</label>
29
+ <select
30
+ id="branch-select"
31
+ value={selectedBranch}
32
+ onChange={(e) => setSelectedBranch(e.target.value)}
33
+ disabled={isLoading}
34
+ >
35
+ <option value="">Select a branch...</option>
36
+ {branches.map((branch) => (
37
+ <option key={branch.name} value={branch.name}>
38
+ {branch.name}
39
+ {branch.isCurrent ? ' (current)' : ''}
40
+ {branch.isRemote ? ' (remote)' : ''}
41
+ </option>
42
+ ))}
43
+ </select>
44
+ </div>
45
+
46
+ <div className="form-group">
47
+ <label>Agent</label>
48
+ <div className="agent-options">
49
+ {AI_AGENTS.map((agent) => (
50
+ <button
51
+ key={agent.id}
52
+ type="button"
53
+ className={`agent-option ${selectedAgent === agent.id ? 'selected' : ''}`}
54
+ onClick={() => setSelectedAgent(agent.id)}
55
+ disabled={isLoading}
56
+ >
57
+ <span className="agent-name">{agent.name}</span>
58
+ </button>
59
+ ))}
60
+ </div>
61
+ </div>
62
+ </div>
63
+
64
+ <div className="modal-footer">
65
+ <button
66
+ type="button"
67
+ className="btn-secondary"
68
+ onClick={closeModal}
69
+ disabled={isLoading}
70
+ >
71
+ Cancel
72
+ </button>
73
+ <button
74
+ type="button"
75
+ className="btn-primary"
76
+ onClick={handleCreate}
77
+ disabled={!selectedBranch || isLoading}
78
+ >
79
+ {isLoading ? 'Creating...' : 'Create Session'}
80
+ </button>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ );
85
+ }
86
+
87
+ export default AddWorktreeModal;