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.
@@ -0,0 +1,114 @@
1
+ import React, { useState } from 'react';
2
+ import { useAppStore } from '../../stores/useAppStore.js';
3
+ import { useSocket } from '../../hooks/useSocket.js';
4
+
5
+ function ConfirmDeleteModal() {
6
+ const {
7
+ modalData,
8
+ terminals,
9
+ closeModal,
10
+ removeTerminal,
11
+ clearAllTerminals,
12
+ clearSessionSelection,
13
+ } = useAppStore();
14
+ const { emit } = useSocket();
15
+ const [isDeleting, setIsDeleting] = useState(false);
16
+
17
+ const sessionIds = modalData?.sessionIds || [];
18
+ const sessionsToDelete = terminals.filter(t => sessionIds.includes(t.sessionId));
19
+ const isDeleteAll = sessionIds.length === terminals.length;
20
+
21
+ const handleDelete = async () => {
22
+ setIsDeleting(true);
23
+
24
+ try {
25
+ // Kill all terminals via socket
26
+ for (const sessionId of sessionIds) {
27
+ emit('terminal:kill', { sessionId });
28
+ }
29
+
30
+ if (isDeleteAll) {
31
+ // Delete all worktrees
32
+ await fetch('/api/worktrees', { method: 'DELETE' });
33
+ clearAllTerminals();
34
+ } else {
35
+ // Batch delete specific worktrees
36
+ const names = sessionsToDelete.map(t => t.worktreeName);
37
+ await fetch('/api/worktrees/batch', {
38
+ method: 'DELETE',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ names }),
41
+ });
42
+
43
+ // Remove terminals from state
44
+ for (const sessionId of sessionIds) {
45
+ removeTerminal(sessionId);
46
+ }
47
+ }
48
+
49
+ clearSessionSelection();
50
+ closeModal();
51
+ } catch (error) {
52
+ console.error('Failed to delete sessions:', error);
53
+ } finally {
54
+ setIsDeleting(false);
55
+ }
56
+ };
57
+
58
+ return (
59
+ <div className="modal-overlay" onClick={closeModal}>
60
+ <div className="modal-content modal-confirm" onClick={(e) => e.stopPropagation()}>
61
+ <div className="modal-header">
62
+ <h2>Confirm Delete</h2>
63
+ <button className="modal-close" onClick={closeModal} type="button">
64
+ &times;
65
+ </button>
66
+ </div>
67
+
68
+ <div className="modal-body">
69
+ <p className="confirm-message">
70
+ {isDeleteAll
71
+ ? 'Are you sure you want to delete ALL sessions?'
72
+ : `Are you sure you want to delete ${sessionIds.length} session${sessionIds.length > 1 ? 's' : ''}?`}
73
+ </p>
74
+
75
+ {sessionsToDelete.length > 0 && (
76
+ <ul className="delete-list">
77
+ {sessionsToDelete.map((session) => (
78
+ <li key={session.sessionId}>
79
+ <span className={`status ${session.status}`} />
80
+ {session.worktreeName}
81
+ </li>
82
+ ))}
83
+ </ul>
84
+ )}
85
+
86
+ <p className="warning-text">
87
+ This action will terminate running agents and delete the worktrees. This cannot be undone.
88
+ </p>
89
+ </div>
90
+
91
+ <div className="modal-footer">
92
+ <button
93
+ type="button"
94
+ className="btn-secondary"
95
+ onClick={closeModal}
96
+ disabled={isDeleting}
97
+ >
98
+ Cancel
99
+ </button>
100
+ <button
101
+ type="button"
102
+ className="btn-danger"
103
+ onClick={handleDelete}
104
+ disabled={isDeleting}
105
+ >
106
+ {isDeleting ? 'Deleting...' : 'Delete'}
107
+ </button>
108
+ </div>
109
+ </div>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ export default ConfirmDeleteModal;
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ import { useAppStore } from '../../stores/useAppStore.js';
3
+ import AddWorktreeModal from './AddWorktreeModal.js';
4
+ import ConfirmDeleteModal from './ConfirmDeleteModal.js';
5
+
6
+ function ModalContainer() {
7
+ const modalType = useAppStore((state) => state.modalType);
8
+
9
+ if (!modalType) return null;
10
+
11
+ switch (modalType) {
12
+ case 'addWorktree':
13
+ return <AddWorktreeModal />;
14
+ case 'confirmDelete':
15
+ return <ConfirmDeleteModal />;
16
+ default:
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export default ModalContainer;
@@ -4,7 +4,15 @@ import { useSocket } from '../../hooks/useSocket.js';
4
4
  import XTerminal from './XTerminal.js';
5
5
 
6
6
  function TerminalPanel() {
7
- const { terminals, activeTerminalIndex, setActiveTerminal, removeTerminal, clearAllTerminals, reset } = useAppStore();
7
+ const {
8
+ terminals,
9
+ activeTerminalIndex,
10
+ activeSessionId,
11
+ setActiveSession,
12
+ removeTerminal,
13
+ clearAllTerminals,
14
+ openModal,
15
+ } = useAppStore();
8
16
  const { emit } = useSocket();
9
17
 
10
18
  const handleCloseTerminal = async (sessionId: string, e: React.MouseEvent) => {
@@ -28,37 +36,27 @@ function TerminalPanel() {
28
36
  removeTerminal(sessionId);
29
37
  };
30
38
 
31
- const handleCloseAll = async () => {
32
- // Kill all terminals via socket
33
- for (const terminal of terminals) {
34
- emit('terminal:kill', { sessionId: terminal.sessionId });
35
- }
36
-
37
- // Delete all worktrees
38
- try {
39
- await fetch('/api/worktrees', {
40
- method: 'DELETE',
41
- });
42
- } catch (error) {
43
- console.error('Failed to delete worktrees:', error);
44
- }
45
-
46
- clearAllTerminals();
47
- reset();
39
+ const handleCloseAll = () => {
40
+ const sessionIds = terminals.map(t => t.sessionId);
41
+ openModal('confirmDelete', { sessionIds });
48
42
  };
49
43
 
50
- const handleBackToSetup = () => {
51
- if (terminals.length > 0) {
52
- const confirmed = window.confirm(
53
- 'This will terminate all running agents and delete all worktrees. Are you sure you want to go back to setup?'
54
- );
55
- if (confirmed) {
56
- handleCloseAll();
57
- }
58
- } else {
59
- reset();
60
- }
61
- };
44
+ // Find active index based on sessionId
45
+ const currentActiveIndex = activeSessionId
46
+ ? terminals.findIndex(t => t.sessionId === activeSessionId)
47
+ : activeTerminalIndex;
48
+ const effectiveActiveIndex = currentActiveIndex >= 0 ? currentActiveIndex : 0;
49
+
50
+ if (terminals.length === 0) {
51
+ return (
52
+ <div className="terminal-panel empty">
53
+ <div className="empty-message">
54
+ <p>No active sessions</p>
55
+ <p>Use the Setup wizard or click "+ Add Session" in the sidebar</p>
56
+ </div>
57
+ </div>
58
+ );
59
+ }
62
60
 
63
61
  return (
64
62
  <div className="terminal-panel">
@@ -66,8 +64,8 @@ function TerminalPanel() {
66
64
  {terminals.map((terminal, index) => (
67
65
  <button
68
66
  key={terminal.sessionId}
69
- className={`terminal-tab ${index === activeTerminalIndex ? 'active' : ''}`}
70
- onClick={() => setActiveTerminal(index)}
67
+ className={`terminal-tab ${terminal.sessionId === activeSessionId || index === effectiveActiveIndex ? 'active' : ''}`}
68
+ onClick={() => setActiveSession(terminal.sessionId)}
71
69
  >
72
70
  <span className={`status ${terminal.status}`} />
73
71
  <span>{terminal.worktreeName}</span>
@@ -82,9 +80,6 @@ function TerminalPanel() {
82
80
  ))}
83
81
 
84
82
  <div className="terminal-actions">
85
- <button onClick={handleBackToSetup}>
86
- Back to Setup
87
- </button>
88
83
  <button className="danger" onClick={handleCloseAll}>
89
84
  Close All
90
85
  </button>
@@ -95,13 +90,13 @@ function TerminalPanel() {
95
90
  {terminals.map((terminal, index) => (
96
91
  <div
97
92
  key={terminal.sessionId}
98
- className={`terminal-wrapper ${index !== activeTerminalIndex ? 'hidden' : ''}`}
93
+ className={`terminal-wrapper ${index !== effectiveActiveIndex ? 'hidden' : ''}`}
99
94
  >
100
95
  <XTerminal
101
96
  sessionId={terminal.sessionId}
102
97
  worktreePath={terminal.worktreePath}
103
98
  agentType={terminal.agentType}
104
- isActive={index === activeTerminalIndex}
99
+ isActive={index === effectiveActiveIndex}
105
100
  />
106
101
  </div>
107
102
  ))}
@@ -1,5 +1,5 @@
1
1
  import { create } from 'zustand';
2
- import type { Branch, TerminalInfo, AgentId, AgentStatus, NavigationStep, StepStatus } from '../../../shared/types/index.js';
2
+ import type { Branch, TerminalInfo, AgentId, AgentStatus, NavigationStep, StepStatus, ModalType, SessionGroup } from '../../../shared/types/index.js';
3
3
  import { STEP_ORDER } from '../../../shared/types/index.js';
4
4
  import { DEFAULT_PARALLEL_COUNT, MIN_PARALLEL_COUNT, MAX_PARALLEL_COUNT } from '../../../shared/constants.js';
5
5
 
@@ -31,6 +31,18 @@ interface AppState {
31
31
  terminals: TerminalInfo[];
32
32
  activeTerminalIndex: number;
33
33
 
34
+ // Session management (new)
35
+ activeSessionId: string | null;
36
+ selectedSessionIds: string[];
37
+
38
+ // LNB state (new)
39
+ isSetupCollapsed: boolean;
40
+ collapsedBranches: string[];
41
+
42
+ // Modal state (new)
43
+ modalType: ModalType;
44
+ modalData: { sessionIds?: string[]; branchName?: string } | null;
45
+
34
46
  // Loading states
35
47
  isLoading: boolean;
36
48
  loadingMessage: string;
@@ -73,6 +85,25 @@ interface AppActions {
73
85
  setActiveTerminal: (index: number) => void;
74
86
  clearAllTerminals: () => void;
75
87
 
88
+ // Session management (new)
89
+ setActiveSession: (sessionId: string | null) => void;
90
+ toggleSessionSelection: (sessionId: string) => void;
91
+ selectAllSessions: () => void;
92
+ clearSessionSelection: () => void;
93
+ removeSelectedSessions: () => Promise<void>;
94
+ getSessionGroups: () => SessionGroup[];
95
+
96
+ // LNB state (new)
97
+ toggleSetupCollapse: () => void;
98
+ toggleBranchCollapse: (branchName: string) => void;
99
+
100
+ // Modal (new)
101
+ openModal: (type: ModalType, data?: { sessionIds?: string[]; branchName?: string }) => void;
102
+ closeModal: () => void;
103
+
104
+ // Single worktree creation (new)
105
+ createSingleWorktree: (branch: string, agentType: AgentId) => Promise<void>;
106
+
76
107
  // Execute
77
108
  execute: () => Promise<void>;
78
109
 
@@ -98,6 +129,12 @@ const initialState: AppState = {
98
129
  worktreeCount: DEFAULT_PARALLEL_COUNT,
99
130
  terminals: [],
100
131
  activeTerminalIndex: 0,
132
+ activeSessionId: null,
133
+ selectedSessionIds: [],
134
+ isSetupCollapsed: false,
135
+ collapsedBranches: [],
136
+ modalType: null,
137
+ modalData: null,
101
138
  isLoading: false,
102
139
  loadingMessage: '',
103
140
  error: null,
@@ -277,7 +314,164 @@ export const useAppStore = create<AppStore>((set, get) => ({
277
314
 
278
315
  setActiveTerminal: (index) => set({ activeTerminalIndex: index }),
279
316
 
280
- clearAllTerminals: () => set({ terminals: [], activeTerminalIndex: 0 }),
317
+ clearAllTerminals: () => set({ terminals: [], activeTerminalIndex: 0, activeSessionId: null, selectedSessionIds: [] }),
318
+
319
+ // Session management
320
+ setActiveSession: (sessionId) => {
321
+ const { terminals } = get();
322
+ const index = terminals.findIndex(t => t.sessionId === sessionId);
323
+ set({
324
+ activeSessionId: sessionId,
325
+ activeTerminalIndex: index >= 0 ? index : 0,
326
+ });
327
+ },
328
+
329
+ toggleSessionSelection: (sessionId) => set((state) => {
330
+ const selectedIds = [...state.selectedSessionIds];
331
+ const index = selectedIds.indexOf(sessionId);
332
+ if (index >= 0) {
333
+ selectedIds.splice(index, 1);
334
+ } else {
335
+ selectedIds.push(sessionId);
336
+ }
337
+ return { selectedSessionIds: selectedIds };
338
+ }),
339
+
340
+ selectAllSessions: () => set((state) => ({
341
+ selectedSessionIds: state.terminals.map(t => t.sessionId),
342
+ })),
343
+
344
+ clearSessionSelection: () => set({ selectedSessionIds: [] }),
345
+
346
+ removeSelectedSessions: async () => {
347
+ const { selectedSessionIds, terminals } = get();
348
+ if (selectedSessionIds.length === 0) return;
349
+
350
+ const names = terminals
351
+ .filter(t => selectedSessionIds.includes(t.sessionId))
352
+ .map(t => t.worktreeName);
353
+
354
+ try {
355
+ const response = await fetch('/api/worktrees/batch', {
356
+ method: 'DELETE',
357
+ headers: { 'Content-Type': 'application/json' },
358
+ body: JSON.stringify({ names }),
359
+ });
360
+
361
+ if (response.ok) {
362
+ set((state) => {
363
+ const remainingTerminals = state.terminals.filter(
364
+ t => !selectedSessionIds.includes(t.sessionId)
365
+ );
366
+ return {
367
+ terminals: remainingTerminals,
368
+ selectedSessionIds: [],
369
+ activeTerminalIndex: Math.min(state.activeTerminalIndex, Math.max(0, remainingTerminals.length - 1)),
370
+ activeSessionId: remainingTerminals.length > 0 ? remainingTerminals[0].sessionId : null,
371
+ };
372
+ });
373
+ }
374
+ } catch (error) {
375
+ console.error('Failed to remove sessions:', error);
376
+ }
377
+ },
378
+
379
+ getSessionGroups: () => {
380
+ const { terminals, collapsedBranches } = get();
381
+ const groupMap = new Map<string, TerminalInfo[]>();
382
+
383
+ for (const terminal of terminals) {
384
+ // Extract base branch name from worktree branch (e.g., "main-project-1" -> "main")
385
+ const baseBranch = terminal.branchName.replace(/-project-\d+$/, '');
386
+ if (!groupMap.has(baseBranch)) {
387
+ groupMap.set(baseBranch, []);
388
+ }
389
+ groupMap.get(baseBranch)!.push(terminal);
390
+ }
391
+
392
+ const groups: SessionGroup[] = [];
393
+ for (const [branchName, sessions] of groupMap) {
394
+ groups.push({
395
+ branchName,
396
+ sessions: sessions.map(s => ({
397
+ ...s,
398
+ id: s.sessionId,
399
+ createdAt: Date.now(),
400
+ })),
401
+ isCollapsed: collapsedBranches.includes(branchName),
402
+ });
403
+ }
404
+
405
+ return groups.sort((a, b) => a.branchName.localeCompare(b.branchName));
406
+ },
407
+
408
+ // LNB state
409
+ toggleSetupCollapse: () => set((state) => ({
410
+ isSetupCollapsed: !state.isSetupCollapsed,
411
+ })),
412
+
413
+ toggleBranchCollapse: (branchName) => set((state) => {
414
+ const collapsed = [...state.collapsedBranches];
415
+ const index = collapsed.indexOf(branchName);
416
+ if (index >= 0) {
417
+ collapsed.splice(index, 1);
418
+ } else {
419
+ collapsed.push(branchName);
420
+ }
421
+ return { collapsedBranches: collapsed };
422
+ }),
423
+
424
+ // Modal
425
+ openModal: (type, data) => set({ modalType: type, modalData: data || null }),
426
+ closeModal: () => set({ modalType: null, modalData: null }),
427
+
428
+ // Single worktree creation
429
+ createSingleWorktree: async (branch, agentType) => {
430
+ const { setLoading, setError, addTerminal, setActiveSession } = get();
431
+
432
+ setLoading(true, 'Creating worktree...');
433
+ setError(null);
434
+
435
+ try {
436
+ const response = await fetch('/api/worktrees/single', {
437
+ method: 'POST',
438
+ headers: { 'Content-Type': 'application/json' },
439
+ body: JSON.stringify({ branch, agentType }),
440
+ });
441
+
442
+ if (!response.ok) {
443
+ const data = await response.json();
444
+ throw new Error(data.error || 'Failed to create worktree');
445
+ }
446
+
447
+ const { worktree } = await response.json();
448
+ const sessionId = `${worktree.name}-${Date.now()}`;
449
+
450
+ addTerminal({
451
+ sessionId,
452
+ worktreePath: worktree.path,
453
+ worktreeName: worktree.name,
454
+ branchName: worktree.branch,
455
+ agentType,
456
+ status: 'initializing',
457
+ });
458
+
459
+ // Auto-switch to the new session
460
+ setActiveSession(sessionId);
461
+
462
+ // Auto-collapse setup and switch to running
463
+ set({
464
+ step: 'running',
465
+ isSetupCollapsed: true,
466
+ modalType: null,
467
+ modalData: null,
468
+ });
469
+ } catch (error) {
470
+ setError(error instanceof Error ? error.message : 'Unknown error');
471
+ } finally {
472
+ setLoading(false);
473
+ }
474
+ },
281
475
 
282
476
  // Execute
283
477
  execute: async () => {
@@ -323,9 +517,12 @@ export const useAppStore = create<AppStore>((set, get) => ({
323
517
  }
324
518
 
325
519
  // Mark all setup steps as complete and transition to running
520
+ const { terminals: updatedTerminals } = get();
326
521
  set({
327
522
  step: 'running',
328
- completedSteps: ['branch', 'agent', 'worktree']
523
+ completedSteps: ['branch', 'agent', 'worktree'],
524
+ isSetupCollapsed: true,
525
+ activeSessionId: updatedTerminals.length > 0 ? updatedTerminals[0].sessionId : null,
329
526
  });
330
527
  } catch (error) {
331
528
  setError(error instanceof Error ? error.message : 'Unknown error');