ccmanager 4.1.18 → 4.1.20

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.
@@ -18,7 +18,6 @@ import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator
18
18
  import { WorktreeService } from '../services/worktreeService.js';
19
19
  import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
20
20
  import { logger } from '../utils/logger.js';
21
- import { AmbiguousBranchError, } from '../types/index.js';
22
21
  import { configReader } from '../services/config/configReader.js';
23
22
  import { ENV_VARS } from '../constants/env.js';
24
23
  import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
@@ -217,29 +216,6 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
217
216
  cancelled = true;
218
217
  };
219
218
  }, [view, pendingMenuSessionLaunch, startSessionForWorktree]);
220
- // Helper function to parse ambiguous branch error and create AmbiguousBranchError
221
- const parseAmbiguousBranchError = (errorMessage) => {
222
- const pattern = /Ambiguous branch '(.+?)' found in multiple remotes: (.+?)\. Please specify which remote to use\./;
223
- const match = errorMessage.match(pattern);
224
- if (!match) {
225
- return null;
226
- }
227
- const branchName = match[1];
228
- const remoteRefsText = match[2];
229
- const remoteRefs = remoteRefsText.split(', ');
230
- // Parse remote refs into RemoteBranchMatch objects
231
- const matches = remoteRefs.map(fullRef => {
232
- const parts = fullRef.split('/');
233
- const remote = parts[0];
234
- const branch = parts.slice(1).join('/');
235
- return {
236
- remote,
237
- branch,
238
- fullRef,
239
- };
240
- });
241
- return new AmbiguousBranchError(branchName, matches);
242
- };
243
219
  // Helper function to handle worktree creation results
244
220
  const handleWorktreeCreationResult = (result, creationData) => {
245
221
  if (result.success) {
@@ -266,21 +242,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
266
242
  handleReturnToMenu();
267
243
  return;
268
244
  }
269
- const errorMessage = result.error || 'Failed to create worktree';
270
- const ambiguousError = parseAmbiguousBranchError(errorMessage);
271
- if (ambiguousError) {
272
- // Handle ambiguous branch error
273
- setPendingWorktreeCreation({
274
- ...creationData,
275
- ambiguousError,
276
- });
277
- navigateWithClear('remote-branch-selector');
278
- }
279
- else {
280
- // Handle regular error
281
- setError(errorMessage);
282
- setView('new-worktree');
283
- }
245
+ setError(result.error || 'Failed to create worktree');
246
+ setView('new-worktree');
284
247
  };
285
248
  const handleMenuAction = async (action) => {
286
249
  switch (action.type) {
@@ -409,9 +372,23 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
409
372
  setView('creating-worktree');
410
373
  // Create the worktree using Effect
411
374
  const result = await Effect.runPromise(Effect.either(worktreeService.createWorktreeEffect(targetPath, branch, request.baseBranch, request.copySessionData, request.copyClaudeDirectory)));
412
- // Transform Effect result to legacy format for handleWorktreeCreationResult
413
375
  if (result._tag === 'Left') {
414
- // Handle error using pattern matching on _tag
376
+ if (result.left._tag === 'AmbiguousBranchError') {
377
+ setPendingWorktreeCreation({
378
+ path: targetPath,
379
+ branch,
380
+ baseBranch: request.baseBranch,
381
+ copySessionData: request.copySessionData,
382
+ copyClaudeDirectory: request.copyClaudeDirectory,
383
+ presetId: request.creationMode === 'prompt' ? request.presetId : undefined,
384
+ initialPrompt: request.creationMode === 'prompt'
385
+ ? request.initialPrompt
386
+ : undefined,
387
+ ambiguousError: result.left,
388
+ });
389
+ navigateWithClear('remote-branch-selector');
390
+ return;
391
+ }
415
392
  const errorMessage = formatPreCreationHookError(result.left);
416
393
  if (result.left._tag === 'ProcessError') {
417
394
  setError(null);
@@ -473,7 +450,11 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
473
450
  const result = await Effect.runPromise(Effect.either(worktreeService.createWorktreeEffect(creationData.path, creationData.branch, selectedRemoteRef, // Use the selected remote reference
474
451
  creationData.copySessionData, creationData.copyClaudeDirectory)));
475
452
  if (result._tag === 'Left') {
476
- // Handle error using pattern matching on _tag
453
+ if (result.left._tag === 'AmbiguousBranchError') {
454
+ setError(result.left.message);
455
+ setView('new-worktree');
456
+ return;
457
+ }
477
458
  const errorMessage = formatPreCreationHookError(result.left);
478
459
  if (result.left._tag === 'ProcessError') {
479
460
  setError(null);
@@ -572,6 +553,16 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
572
553
  setActiveSession(session);
573
554
  navigateWithClear('session');
574
555
  };
556
+ const handleSessionActionFromDashboard = (session, project) => {
557
+ const projectSessionManager = globalSessionOrchestrator.getManagerForProject(project.path);
558
+ setSessionManager(projectSessionManager);
559
+ setWorktreeService(new WorktreeService(project.path));
560
+ setSessionActionsTarget({
561
+ session,
562
+ worktreePath: session.worktreePath,
563
+ });
564
+ navigateWithClear('session-actions');
565
+ };
575
566
  const handleBackToProjectList = () => {
576
567
  // Sessions persist in their project-specific managers
577
568
  setSelectedProject(null);
@@ -587,7 +578,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
587
578
  if (!projectsDir) {
588
579
  return (_jsx(Box, { children: _jsxs(Text, { color: "red", children: ["Error: ", MULTI_PROJECT_ERRORS.NO_PROJECTS_DIR] }) }));
589
580
  }
590
- return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, error: error, onDismissError: () => setError(null), version: version }));
581
+ return (_jsx(Dashboard, { projectsDir: projectsDir, onSelectSession: handleSelectSessionFromDashboard, onSelectProject: handleSelectProject, onSessionAction: handleSessionActionFromDashboard, error: error, onDismissError: () => setError(null), version: version }));
591
582
  }
592
583
  if (view === 'menu') {
593
584
  return (_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, onMenuAction: handleMenuAction, onSelectRecentProject: handleSelectProject, error: error, onDismissError: () => setError(null), projectName: selectedProject?.name, multiProject: multiProject, version: version }, menuKey));
@@ -4,6 +4,7 @@ interface DashboardProps {
4
4
  projectsDir: string;
5
5
  onSelectSession: (session: ISession, project: GitProject) => void;
6
6
  onSelectProject: (project: GitProject) => void;
7
+ onSessionAction?: (session: ISession, project: GitProject) => void;
7
8
  error: string | null;
8
9
  onDismissError: () => void;
9
10
  version: string;
@@ -12,7 +12,7 @@ import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../c
12
12
  import { useSearchMode } from '../hooks/useSearchMode.js';
13
13
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
14
14
  import { useGitStatus } from '../hooks/useGitStatus.js';
15
- import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
15
+ import { truncateString, calculateColumnPositions, assembleSessionLabel, formatRelativeDate, displaySuffix, } from '../utils/worktreeUtils.js';
16
16
  import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
17
17
  import SearchableList from './SearchableList.js';
18
18
  const MAX_BRANCH_NAME_LENGTH = 70;
@@ -63,7 +63,7 @@ function resolveProjectDisplayNames(projects) {
63
63
  }
64
64
  return displayNames;
65
65
  }
66
- const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDismissError, version, }) => {
66
+ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, onSessionAction, error, onDismissError, version, }) => {
67
67
  const [projects, setProjects] = useState([]);
68
68
  const [recentProjects, setRecentProjects] = useState([]);
69
69
  const [loading, setLoading] = useState(true);
@@ -73,6 +73,8 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
73
73
  const [sessionEntries, setSessionEntries] = useState([]);
74
74
  const [baseSessionWorktrees, setBaseSessionWorktrees] = useState([]);
75
75
  const [sessionRefreshKey, setSessionRefreshKey] = useState(0);
76
+ const [highlightedSession, setHighlightedSession] = useState(null);
77
+ const [highlightedProject, setHighlightedProject] = useState(null);
76
78
  const displayError = error || loadError;
77
79
  const { isSearchMode, searchQuery, selectedIndex, setSearchQuery } = useSearchMode(items.length, {
78
80
  isDisabled: !!displayError,
@@ -220,7 +222,10 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
220
222
  : wt.path.split('/').pop() || 'detached';
221
223
  const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
222
224
  const isMain = wt.isMainWorktree ? ' (main)' : '';
223
- const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${status}`;
225
+ const worktreeSessionCount = sessionEntries.filter(e => e.worktree.path === entry.worktree.path &&
226
+ e.projectPath === entry.projectPath).length;
227
+ const sessionSuffix = displaySuffix(entry.session, worktreeSessionCount > 1);
228
+ const baseLabel = `${entry.projectName} :: ${branchName}${isMain}${sessionSuffix}${status}`;
224
229
  let fileChanges = '';
225
230
  let aheadBehind = '';
226
231
  let parentBranch = '';
@@ -407,6 +412,11 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
407
412
  return;
408
413
  }
409
414
  switch (keyPressed) {
415
+ case ' ':
416
+ if (highlightedSession && highlightedProject && onSessionAction) {
417
+ onSessionAction(highlightedSession, highlightedProject);
418
+ }
419
+ break;
410
420
  case 'r':
411
421
  refreshAll();
412
422
  break;
@@ -447,10 +457,20 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
447
457
  if (!item)
448
458
  return;
449
459
  handleSelect(item);
460
+ }, onHighlight: raw => {
461
+ const item = items.find(i => i.value === raw?.value);
462
+ if (item?.type === 'session') {
463
+ setHighlightedSession(item.session);
464
+ setHighlightedProject(item.project);
465
+ }
466
+ else {
467
+ setHighlightedSession(null);
468
+ setHighlightedProject(null);
469
+ }
450
470
  }, isFocused: !displayError, limit: limit, initialIndex: selectedIndex }) })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
451
471
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
452
472
  : searchQuery
453
- ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
454
- : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search R-Refresh Q-Quit' })] })] }));
473
+ ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Space-Session actions (session rows only) R-Refresh Q-Quit`
474
+ : 'Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Space-Session actions (session rows only) R-Refresh Q-Quit' })] })] }));
455
475
  };
456
476
  export default Dashboard;
@@ -1,26 +1,50 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render } from 'ink-testing-library';
3
3
  import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ const capturedHandlers = { inputHandlers: [], onHighlight: null };
5
+ const makeKey = (overrides = {}) => ({
6
+ upArrow: false,
7
+ downArrow: false,
8
+ leftArrow: false,
9
+ rightArrow: false,
10
+ pageDown: false,
11
+ pageUp: false,
12
+ home: false,
13
+ end: false,
14
+ return: false,
15
+ escape: false,
16
+ ctrl: false,
17
+ shift: false,
18
+ tab: false,
19
+ backspace: false,
20
+ delete: false,
21
+ meta: false,
22
+ ...overrides,
23
+ });
4
24
  // Mock bunTerminal to avoid native module loading issues
5
25
  vi.mock('../services/bunTerminal.js', () => ({
6
26
  spawn: vi.fn(function () {
7
27
  return null;
8
28
  }),
9
29
  }));
10
- // Import the actual component code but skip the useInput hook
30
+ // Import the actual component code but capture useInput handlers
11
31
  vi.mock('ink', async () => {
12
32
  const actual = await vi.importActual('ink');
13
33
  return {
14
34
  ...actual,
15
- useInput: vi.fn(),
35
+ useInput: vi.fn((handler) => {
36
+ capturedHandlers.inputHandlers.push(handler);
37
+ }),
16
38
  };
17
39
  });
18
- // Mock SelectInput to render items as simple text
40
+ // Mock SelectInput to render items as simple text and capture onHighlight
19
41
  vi.mock('ink-select-input', async () => {
20
42
  const React = await vi.importActual('react');
21
43
  const { Text, Box } = await vi.importActual('ink');
22
44
  return {
23
- default: ({ items }) => {
45
+ default: ({ items, onHighlight, }) => {
46
+ if (onHighlight)
47
+ capturedHandlers.onHighlight = onHighlight;
24
48
  return React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label)));
25
49
  },
26
50
  };
@@ -120,6 +144,8 @@ describe('Dashboard', () => {
120
144
  ];
121
145
  beforeEach(() => {
122
146
  vi.clearAllMocks();
147
+ capturedHandlers.inputHandlers = [];
148
+ capturedHandlers.onHighlight = null;
123
149
  vi.mocked(projectManager.instance.discoverProjectsEffect).mockReturnValue(Effect.succeed(mockProjects));
124
150
  vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([]);
125
151
  vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([]);
@@ -308,6 +334,7 @@ describe('Dashboard', () => {
308
334
  const { lastFrame } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
309
335
  expect(lastFrame()).toContain('Controls:');
310
336
  expect(lastFrame()).toContain('0-9 Quick Select');
337
+ expect(lastFrame()).toContain('Space-Session actions');
311
338
  expect(lastFrame()).toContain('R-Refresh');
312
339
  expect(lastFrame()).toContain('Q-Quit');
313
340
  });
@@ -345,4 +372,261 @@ describe('Dashboard', () => {
345
372
  // shared-lib should appear first (index 0) because it's recent
346
373
  expect(frame).toContain('0 ❯ shared-lib');
347
374
  });
375
+ it('should show session name suffix in label when session has a name', async () => {
376
+ const mockSession = {
377
+ id: 'session-named',
378
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
379
+ sessionNumber: 1,
380
+ sessionName: 'my-feature',
381
+ lastActivity: new Date(),
382
+ isActive: true,
383
+ stateMutex: {
384
+ getSnapshot: () => ({
385
+ state: 'busy',
386
+ backgroundTaskCount: 0,
387
+ teamMemberCount: 0,
388
+ }),
389
+ },
390
+ };
391
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
392
+ '/projects/my-app',
393
+ ]);
394
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
395
+ mockSession,
396
+ ]);
397
+ vi.mocked(WorktreeService).mockImplementation(function () {
398
+ return {
399
+ getWorktreesEffect: () => Effect.succeed([
400
+ {
401
+ path: '/projects/my-app/worktrees/feature-auth',
402
+ branch: 'feature/auth',
403
+ isMainWorktree: false,
404
+ hasSession: true,
405
+ },
406
+ ]),
407
+ getGitRootPath: () => '/projects/my-app',
408
+ };
409
+ });
410
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
411
+ await new Promise(resolve => setTimeout(resolve, 200));
412
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
413
+ await vi.waitFor(() => {
414
+ return lastFrame()?.includes('Active Sessions') ?? false;
415
+ }, { timeout: 3000 });
416
+ expect(lastFrame()).toContain('my-app :: feature/auth: my-feature');
417
+ });
418
+ it('should show session number suffix when multiple unnamed sessions on same worktree', async () => {
419
+ const mockSessions = [
420
+ {
421
+ id: 'session-1',
422
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
423
+ sessionNumber: 1,
424
+ lastActivity: new Date(),
425
+ isActive: true,
426
+ stateMutex: {
427
+ getSnapshot: () => ({
428
+ state: 'busy',
429
+ backgroundTaskCount: 0,
430
+ teamMemberCount: 0,
431
+ }),
432
+ },
433
+ },
434
+ {
435
+ id: 'session-2',
436
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
437
+ sessionNumber: 2,
438
+ lastActivity: new Date(),
439
+ isActive: true,
440
+ stateMutex: {
441
+ getSnapshot: () => ({
442
+ state: 'idle',
443
+ backgroundTaskCount: 0,
444
+ teamMemberCount: 0,
445
+ }),
446
+ },
447
+ },
448
+ ];
449
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
450
+ '/projects/my-app',
451
+ ]);
452
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue(mockSessions);
453
+ vi.mocked(WorktreeService).mockImplementation(function () {
454
+ return {
455
+ getWorktreesEffect: () => Effect.succeed([
456
+ {
457
+ path: '/projects/my-app/worktrees/feature-auth',
458
+ branch: 'feature/auth',
459
+ isMainWorktree: false,
460
+ hasSession: true,
461
+ },
462
+ ]),
463
+ getGitRootPath: () => '/projects/my-app',
464
+ };
465
+ });
466
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
467
+ await new Promise(resolve => setTimeout(resolve, 200));
468
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
469
+ await vi.waitFor(() => {
470
+ return lastFrame()?.includes('Active Sessions') ?? false;
471
+ }, { timeout: 3000 });
472
+ const frame = lastFrame();
473
+ expect(frame).toContain('#1');
474
+ expect(frame).toContain('#2');
475
+ });
476
+ it('should show no session suffix for single unnamed session', async () => {
477
+ const mockSession = {
478
+ id: 'session-single',
479
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
480
+ sessionNumber: 1,
481
+ lastActivity: new Date(),
482
+ isActive: true,
483
+ stateMutex: {
484
+ getSnapshot: () => ({
485
+ state: 'busy',
486
+ backgroundTaskCount: 0,
487
+ teamMemberCount: 0,
488
+ }),
489
+ },
490
+ };
491
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
492
+ '/projects/my-app',
493
+ ]);
494
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
495
+ mockSession,
496
+ ]);
497
+ vi.mocked(WorktreeService).mockImplementation(function () {
498
+ return {
499
+ getWorktreesEffect: () => Effect.succeed([
500
+ {
501
+ path: '/projects/my-app/worktrees/feature-auth',
502
+ branch: 'feature/auth',
503
+ isMainWorktree: false,
504
+ hasSession: true,
505
+ },
506
+ ]),
507
+ getGitRootPath: () => '/projects/my-app',
508
+ };
509
+ });
510
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
511
+ await new Promise(resolve => setTimeout(resolve, 200));
512
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
513
+ await vi.waitFor(() => {
514
+ return lastFrame()?.includes('Active Sessions') ?? false;
515
+ }, { timeout: 3000 });
516
+ const frame = lastFrame();
517
+ expect(frame).toContain('my-app :: feature/auth');
518
+ expect(frame).not.toContain('feature/auth:');
519
+ expect(frame).not.toContain('feature/auth #');
520
+ });
521
+ it('should call onSessionAction when space is pressed on highlighted session', async () => {
522
+ const mockOnSessionAction = vi.fn();
523
+ const mockSession = {
524
+ id: 'session-action',
525
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
526
+ sessionNumber: 1,
527
+ lastActivity: new Date(),
528
+ isActive: true,
529
+ stateMutex: {
530
+ getSnapshot: () => ({
531
+ state: 'busy',
532
+ backgroundTaskCount: 0,
533
+ teamMemberCount: 0,
534
+ }),
535
+ },
536
+ };
537
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
538
+ '/projects/my-app',
539
+ ]);
540
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
541
+ mockSession,
542
+ ]);
543
+ vi.mocked(WorktreeService).mockImplementation(function () {
544
+ return {
545
+ getWorktreesEffect: () => Effect.succeed([
546
+ {
547
+ path: '/projects/my-app/worktrees/feature-auth',
548
+ branch: 'feature/auth',
549
+ isMainWorktree: false,
550
+ hasSession: true,
551
+ },
552
+ ]),
553
+ getGitRootPath: () => '/projects/my-app',
554
+ };
555
+ });
556
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
557
+ await new Promise(resolve => setTimeout(resolve, 200));
558
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
559
+ await vi.waitFor(() => {
560
+ return lastFrame()?.includes('Active Sessions') ?? false;
561
+ }, { timeout: 3000 });
562
+ // Enable the useInput handler (Dashboard guards on process.stdin.setRawMode)
563
+ const origSetRawMode = process.stdin.setRawMode;
564
+ process.stdin.setRawMode = vi.fn();
565
+ // Simulate highlighting a session item
566
+ expect(capturedHandlers.onHighlight).not.toBeNull();
567
+ capturedHandlers.onHighlight({
568
+ value: 'session-session-action',
569
+ label: 'my-app :: feature/auth',
570
+ });
571
+ // Force re-render to flush state update — new useInput handler captures updated closure
572
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
573
+ await new Promise(resolve => setTimeout(resolve, 50));
574
+ // Use the latest Dashboard handler (last in array — has updated highlightedSession state)
575
+ const latestHandler = capturedHandlers.inputHandlers[capturedHandlers.inputHandlers.length - 1];
576
+ expect(latestHandler).toBeDefined();
577
+ latestHandler(' ', makeKey());
578
+ expect(mockOnSessionAction).toHaveBeenCalledWith(expect.objectContaining({ id: 'session-action' }), expect.objectContaining({ path: '/projects/my-app' }));
579
+ process.stdin.setRawMode = origSetRawMode;
580
+ });
581
+ it('should not call onSessionAction when space is pressed with no highlighted session', async () => {
582
+ const mockOnSessionAction = vi.fn();
583
+ const mockSession = {
584
+ id: 'session-noop',
585
+ worktreePath: '/projects/my-app/worktrees/feature-auth',
586
+ sessionNumber: 1,
587
+ lastActivity: new Date(),
588
+ isActive: true,
589
+ stateMutex: {
590
+ getSnapshot: () => ({
591
+ state: 'busy',
592
+ backgroundTaskCount: 0,
593
+ teamMemberCount: 0,
594
+ }),
595
+ },
596
+ };
597
+ vi.mocked(globalSessionOrchestrator.getProjectPaths).mockReturnValue([
598
+ '/projects/my-app',
599
+ ]);
600
+ vi.mocked(globalSessionOrchestrator.getProjectSessions).mockReturnValue([
601
+ mockSession,
602
+ ]);
603
+ vi.mocked(WorktreeService).mockImplementation(function () {
604
+ return {
605
+ getWorktreesEffect: () => Effect.succeed([
606
+ {
607
+ path: '/projects/my-app/worktrees/feature-auth',
608
+ branch: 'feature/auth',
609
+ isMainWorktree: false,
610
+ hasSession: true,
611
+ },
612
+ ]),
613
+ getGitRootPath: () => '/projects/my-app',
614
+ };
615
+ });
616
+ const { lastFrame, rerender } = render(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
617
+ await new Promise(resolve => setTimeout(resolve, 200));
618
+ rerender(_jsx(Dashboard, { projectsDir: "/projects", onSelectSession: mockOnSelectSession, onSelectProject: mockOnSelectProject, onSessionAction: mockOnSessionAction, error: null, onDismissError: mockOnDismissError, version: "3.8.1" }));
619
+ await vi.waitFor(() => {
620
+ return lastFrame()?.includes('Active Sessions') ?? false;
621
+ }, { timeout: 3000 });
622
+ // Enable the useInput handler (Dashboard guards on process.stdin.setRawMode)
623
+ const origSetRawMode = process.stdin.setRawMode;
624
+ process.stdin.setRawMode = vi.fn();
625
+ // Do NOT call onHighlight — highlightedSession stays null
626
+ const dashboardHandler = capturedHandlers.inputHandlers.find(handler => handler !== capturedHandlers.inputHandlers[0]);
627
+ expect(dashboardHandler).toBeDefined();
628
+ dashboardHandler(' ', makeKey());
629
+ expect(mockOnSessionAction).not.toHaveBeenCalled();
630
+ process.stdin.setRawMode = origSetRawMode;
631
+ });
348
632
  });
@@ -1,5 +1,5 @@
1
1
  import { Effect } from 'effect';
2
- import { Worktree, CreateWorktreeResult, MergeConfig } from '../types/index.js';
2
+ import { Worktree, CreateWorktreeResult, AmbiguousBranchError, MergeConfig } from '../types/index.js';
3
3
  import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
4
4
  /**
5
5
  * WorktreeService - Git worktree management with Effect-based error handling
@@ -53,6 +53,7 @@ export declare class WorktreeService {
53
53
  * @throws {AmbiguousBranchError} When branch exists in multiple remotes
54
54
  */
55
55
  private resolveBranchReference;
56
+ private resolveBranchReferenceEffect;
56
57
  /**
57
58
  * SYNCHRONOUS HELPER: Gets all git remotes for this repository.
58
59
  *
@@ -323,7 +324,7 @@ export declare class WorktreeService {
323
324
  * @throws {GitError} When git worktree add command fails
324
325
  * @throws {FileSystemError} When session data copy fails
325
326
  */
326
- createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<CreateWorktreeResult, GitError | FileSystemError | ProcessError, never>;
327
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<CreateWorktreeResult, GitError | FileSystemError | ProcessError | AmbiguousBranchError, never>;
327
328
  /**
328
329
  * Effect-based deleteWorktree operation
329
330
  * May fail with GitError
@@ -162,6 +162,18 @@ export class WorktreeService {
162
162
  return branchName;
163
163
  }
164
164
  }
165
+ resolveBranchReferenceEffect(branchName) {
166
+ return Effect.try({
167
+ try: () => this.resolveBranchReference(branchName),
168
+ catch: error => {
169
+ if (error instanceof AmbiguousBranchError)
170
+ return error;
171
+ // resolveBranchReference only re-throws AmbiguousBranchError; all
172
+ // other errors are swallowed internally. This path is unreachable.
173
+ throw error;
174
+ },
175
+ });
176
+ }
165
177
  /**
166
178
  * SYNCHRONOUS HELPER: Gets all git remotes for this repository.
167
179
  *
@@ -812,12 +824,18 @@ export class WorktreeService {
812
824
  if (localBranchExists) {
813
825
  command = `git worktree add "${resolvedPath}" "${branch}"`;
814
826
  }
827
+ else if (baseBranch.endsWith(`/${branch}`)) {
828
+ // baseBranch is already a remote-tracking ref for branch
829
+ // (e.g. "origin/feature/x" after the user resolved an ambiguity).
830
+ // Use it directly to avoid re-triggering AmbiguousBranchError.
831
+ command = `git worktree add -b "${branch}" "${resolvedPath}" "${baseBranch}"`;
832
+ }
815
833
  else {
816
- const resolvedRef = self.resolveBranchReference(branch);
834
+ const resolvedRef = yield* self.resolveBranchReferenceEffect(branch);
817
835
  const isRemoteBranch = resolvedRef !== branch;
818
836
  const startPoint = isRemoteBranch
819
837
  ? resolvedRef
820
- : self.resolveBranchReference(baseBranch);
838
+ : yield* self.resolveBranchReferenceEffect(baseBranch);
821
839
  command = `git worktree add -b "${branch}" "${resolvedPath}" "${startPoint}"`;
822
840
  }
823
841
  // Execute the worktree creation command
@@ -5,6 +5,7 @@ import { existsSync, statSync } from 'fs';
5
5
  import { configReader } from './config/configReader.js';
6
6
  import { Effect } from 'effect';
7
7
  import { GitError, ProcessError } from '../types/errors.js';
8
+ import { AmbiguousBranchError } from '../types/index.js';
8
9
  // Mock child_process module
9
10
  vi.mock('child_process');
10
11
  // Mock fs module
@@ -369,6 +370,61 @@ origin/feature/test
369
370
  expect(result).toBe('foo/bar-xyz');
370
371
  });
371
372
  });
373
+ describe('resolveBranchReferenceEffect', () => {
374
+ it('should succeed with remote ref when single remote has the branch', async () => {
375
+ mockedExecSync.mockImplementation((cmd, _options) => {
376
+ if (typeof cmd === 'string') {
377
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
378
+ throw new Error('Local branch not found');
379
+ }
380
+ if (cmd === 'git remote') {
381
+ return 'origin\nupstream\n';
382
+ }
383
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/foo/bar-xyz')) {
384
+ return '';
385
+ }
386
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/upstream/foo/bar-xyz')) {
387
+ throw new Error('Remote branch not found in upstream');
388
+ }
389
+ }
390
+ throw new Error('Command not mocked: ' + cmd);
391
+ });
392
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
393
+ const effect = service.resolveBranchReferenceEffect('foo/bar-xyz');
394
+ const result = await Effect.runPromise(Effect.either(effect));
395
+ expect(result._tag).toBe('Right');
396
+ if (result._tag === 'Right') {
397
+ expect(result.right).toBe('origin/foo/bar-xyz');
398
+ }
399
+ });
400
+ it('should fail with AmbiguousBranchError (not Die) when multiple remotes have the branch', async () => {
401
+ mockedExecSync.mockImplementation((cmd, _options) => {
402
+ if (typeof cmd === 'string') {
403
+ if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
404
+ throw new Error('Local branch not found');
405
+ }
406
+ if (cmd === 'git remote') {
407
+ return 'origin\nupstream\n';
408
+ }
409
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/foo/bar-xyz') ||
410
+ cmd.includes('show-ref --verify --quiet refs/remotes/upstream/foo/bar-xyz')) {
411
+ return '';
412
+ }
413
+ }
414
+ throw new Error('Command not mocked: ' + cmd);
415
+ });
416
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
417
+ const effect = service.resolveBranchReferenceEffect('foo/bar-xyz');
418
+ const result = await Effect.runPromise(Effect.either(effect));
419
+ expect(result._tag).toBe('Left');
420
+ if (result._tag === 'Left') {
421
+ expect(result.left).toBeInstanceOf(AmbiguousBranchError);
422
+ expect(result.left._tag).toBe('AmbiguousBranchError');
423
+ expect(result.left.branchName).toBe('foo/bar-xyz');
424
+ expect(result.left.matches).toHaveLength(2);
425
+ }
426
+ });
427
+ });
372
428
  describe('hasClaudeDirectoryInBranchEffect', () => {
373
429
  it('should return Effect with true when .claude directory exists in branch worktree', async () => {
374
430
  mockedExecSync.mockImplementation((cmd, _options) => {
@@ -695,6 +751,65 @@ branch refs/heads/feature
695
751
  expect(worktreeAddCmd).toContain('-b "feature/remote-only"');
696
752
  expect(worktreeAddCmd).toContain('"origin/feature/remote-only"');
697
753
  });
754
+ it('should return Effect Left with AmbiguousBranchError when branch exists in multiple remotes', async () => {
755
+ mockedExecSync.mockImplementation((cmd, _options) => {
756
+ if (typeof cmd === 'string') {
757
+ if (cmd === 'git rev-parse --git-common-dir') {
758
+ return '/fake/path/.git\n';
759
+ }
760
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
761
+ throw new Error('Branch not found');
762
+ }
763
+ if (cmd === 'git remote') {
764
+ return 'origin\nkbwo-fork\n';
765
+ }
766
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
767
+ return ''; // Both remotes have the branch
768
+ }
769
+ }
770
+ throw new Error('Command not mocked: ' + cmd);
771
+ });
772
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/feed-mention', 'main');
773
+ const result = await Effect.runPromise(Effect.either(effect));
774
+ expect(result._tag).toBe('Left');
775
+ if (result._tag === 'Left') {
776
+ expect(result.left).toBeInstanceOf(AmbiguousBranchError);
777
+ expect(result.left._tag).toBe('AmbiguousBranchError');
778
+ expect(result.left.branchName).toBe('feature/feed-mention');
779
+ expect(result.left.matches).toHaveLength(2);
780
+ expect(result.left.matches.map(m => m.remote)).toEqual(['origin', 'kbwo-fork']);
781
+ }
782
+ });
783
+ it('should succeed when baseBranch is already a resolved remote ref for branch (retry after disambiguation)', async () => {
784
+ const executedCommands = [];
785
+ mockedExecSync.mockImplementation((cmd, _options) => {
786
+ if (typeof cmd === 'string') {
787
+ executedCommands.push(cmd);
788
+ if (cmd === 'git rev-parse --git-common-dir') {
789
+ return '/fake/path/.git\n';
790
+ }
791
+ // No local branch
792
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
793
+ throw new Error('Branch not found');
794
+ }
795
+ if (cmd.includes('git worktree add')) {
796
+ return '';
797
+ }
798
+ }
799
+ return '';
800
+ });
801
+ // Simulate the retry call: user selected "origin/feature/feed-mention",
802
+ // which is passed as baseBranch.
803
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/feed-mention', 'origin/feature/feed-mention');
804
+ const result = await Effect.runPromise(Effect.either(effect));
805
+ expect(result._tag).toBe('Right');
806
+ // Should use baseBranch directly as startPoint without calling show-ref
807
+ const worktreeAddCmd = executedCommands.find(c => c.includes('git worktree add'));
808
+ expect(worktreeAddCmd).toContain('-b "feature/feed-mention"');
809
+ expect(worktreeAddCmd).toContain('"origin/feature/feed-mention"');
810
+ // show-ref for remotes must NOT be called (no re-resolution)
811
+ expect(executedCommands.some(c => c.includes('show-ref') && c.includes('refs/remotes/'))).toBe(false);
812
+ });
698
813
  it('should return Effect that fails with GitError on git command failure', async () => {
699
814
  mockedExecSync.mockImplementation((cmd, _options) => {
700
815
  if (typeof cmd === 'string') {
@@ -4,7 +4,8 @@ import type { SerializeAddon } from '@xterm/addon-serialize';
4
4
  import { GitStatus } from '../utils/gitStatus.js';
5
5
  import { Mutex, SessionStateData } from '../utils/mutex.js';
6
6
  import type { StateDetector } from '../services/stateDetector/types.js';
7
- import type { ProcessError } from './errors.js';
7
+ import type { Effect } from 'effect';
8
+ import type { GitError, FileSystemError, ProcessError } from './errors.js';
8
9
  export type Terminal = InstanceType<typeof pkg.Terminal>;
9
10
  export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'pending_auto_approval';
10
11
  export type StateDetectionStrategy = 'claude' | 'gemini' | 'codex' | 'cursor' | 'github-copilot' | 'cline' | 'opencode' | 'kimi';
@@ -249,16 +250,17 @@ export interface RemoteBranchMatch {
249
250
  fullRef: string;
250
251
  }
251
252
  export declare class AmbiguousBranchError extends Error {
253
+ readonly _tag: "AmbiguousBranchError";
252
254
  branchName: string;
253
255
  matches: RemoteBranchMatch[];
254
256
  constructor(branchName: string, matches: RemoteBranchMatch[]);
255
257
  }
256
258
  export interface IWorktreeService {
257
- getWorktreesEffect(): import('effect').Effect.Effect<Worktree[], import('../types/errors.js').GitError, never>;
259
+ getWorktreesEffect(): Effect.Effect<Worktree[], GitError, never>;
258
260
  getGitRootPath(): string;
259
- createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): import('effect').Effect.Effect<CreateWorktreeResult, import('../types/errors.js').GitError | import('../types/errors.js').FileSystemError | import('../types/errors.js').ProcessError, never>;
261
+ createWorktreeEffect(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): Effect.Effect<CreateWorktreeResult, GitError | FileSystemError | ProcessError | AmbiguousBranchError, never>;
260
262
  deleteWorktreeEffect(worktreePath: string, options?: {
261
263
  deleteBranch?: boolean;
262
- }): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
263
- mergeWorktreeEffect(sourceBranch: string, targetBranch: string, operation?: 'merge' | 'rebase', mergeConfig?: MergeConfig): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
264
+ }): Effect.Effect<void, GitError, never>;
265
+ mergeWorktreeEffect(sourceBranch: string, targetBranch: string, operation?: 'merge' | 'rebase', mergeConfig?: MergeConfig): Effect.Effect<void, GitError, never>;
264
266
  }
@@ -3,6 +3,7 @@ export const DEFAULT_SHORTCUTS = {
3
3
  cancel: { key: 'escape' },
4
4
  };
5
5
  export class AmbiguousBranchError extends Error {
6
+ _tag = 'AmbiguousBranchError';
6
7
  branchName;
7
8
  matches;
8
9
  constructor(branchName, matches) {
@@ -29,6 +29,7 @@ export declare function extractBranchParts(branchName: string): {
29
29
  prefix?: string;
30
30
  name: string;
31
31
  };
32
+ export declare function displaySuffix(session: Session, multipleForWorktree: boolean): string;
32
33
  /**
33
34
  * Prepares session items for display.
34
35
  * Supports multiple sessions per worktree.
@@ -122,7 +122,7 @@ function indexSessionsByWorktree(sessions) {
122
122
  }
123
123
  return { byWorktreePath, maxAccessAt };
124
124
  }
125
- function displaySuffix(session, multipleForWorktree) {
125
+ export function displaySuffix(session, multipleForWorktree) {
126
126
  if (multipleForWorktree) {
127
127
  return session.sessionName
128
128
  ? `: ${session.sessionName}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.18",
3
+ "version": "4.1.20",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.18",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.18",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.18",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.18",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.18"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.20",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.20",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.20",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.20",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.20"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",