@brimveyn/aimux 1.14.14 → 1.14.16

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.
@@ -33,6 +33,24 @@ export async function getHeadSha(cwd: string): Promise<string | undefined> {
33
33
  return result.text().trim() || undefined
34
34
  }
35
35
 
36
+ // Local branch names, ordered most-recently-committed first so the likely base
37
+ // surfaces near the top of the picker.
38
+ export async function listLocalBranches(cwd: string): Promise<string[]> {
39
+ // The format must be interpolated, not inlined: Bun's shell parses a bare
40
+ // `%(refname:short)` and chokes on the parentheses.
41
+ const format = '%(refname:short)'
42
+ const result =
43
+ await $`git -C ${cwd} for-each-ref --sort=-committerdate refs/heads --format=${format}`
44
+ .quiet()
45
+ .nothrow()
46
+ if (result.exitCode !== 0) return []
47
+ return result
48
+ .text()
49
+ .split('\n')
50
+ .map((line) => line.trim())
51
+ .filter((line) => line !== '')
52
+ }
53
+
36
54
  export async function createGitWorktree({
37
55
  baseRef,
38
56
  branchName,
@@ -52,6 +70,33 @@ export async function createGitWorktree({
52
70
  }
53
71
  }
54
72
 
73
+ // Force-delete a local branch. Returns false (without throwing) when git
74
+ // refuses — notably when the branch is still checked out in a live worktree,
75
+ // which is exactly how orphan-pruning stays safe.
76
+ export async function deleteGitBranch(repoPath: string, branch: string): Promise<boolean> {
77
+ const result = await $`git -C ${repoPath} branch -D ${branch}`.quiet().nothrow()
78
+ return result.exitCode === 0
79
+ }
80
+
81
+ export async function pruneGitWorktrees(repoPath: string): Promise<void> {
82
+ await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
83
+ }
84
+
85
+ // Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
86
+ // to delete branches still checked out in a live worktree, so this only removes
87
+ // true orphans. Returns the number of branches removed.
88
+ export async function pruneOrphanAimuxBranches(repoPath: string): Promise<number> {
89
+ await pruneGitWorktrees(repoPath)
90
+ const branches = (await listLocalBranches(repoPath)).filter((branch) =>
91
+ branch.startsWith('aimux/')
92
+ )
93
+ let removed = 0
94
+ for (const branch of branches) {
95
+ if (await deleteGitBranch(repoPath, branch)) removed++
96
+ }
97
+ return removed
98
+ }
99
+
55
100
  export async function removeGitWorktree({
56
101
  force,
57
102
  repoPath,
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
26
26
  const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
27
27
  'ai-usage': 'modal.ai-usage',
28
28
  'update-available': 'modal.update-available',
29
+ 'worktree-delete-confirm': 'modal.worktree-delete-confirm',
29
30
  'worktree-move': 'modal.worktree-move',
30
31
  }
31
32
 
@@ -51,6 +52,9 @@ export function deriveModeId(state: AppState): ModeId {
51
52
  if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
52
53
  return 'modal.new-tab.editing-command'
53
54
  }
55
+ if (state.modal.type === 'new-tab' && state.modal.worktreeDeletePrompt !== null) {
56
+ return 'modal.new-tab.worktree-delete-confirm'
57
+ }
54
58
  if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
55
59
  return 'modal.git-commit.confirm'
56
60
  }
@@ -8,8 +8,13 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
8
8
  'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
9
9
  'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
10
10
  'modal.help.filtering': ['navigation'],
11
- 'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
11
+ 'modal.new-tab.command-edit': [
12
+ 'navigation',
13
+ 'modal.new-tab.editing-command',
14
+ 'modal.new-tab.worktree-delete-confirm',
15
+ ],
12
16
  'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
17
+ 'modal.new-tab.worktree-delete-confirm': ['navigation', 'modal.new-tab.command-edit'],
13
18
  'modal.rename-tab': ['navigation'],
14
19
  'modal.session-name': ['modal.session-picker.filtering', 'navigation'],
15
20
  'modal.session-picker.filtering': ['navigation', 'modal.session-name', 'modal.create-session'],
@@ -18,6 +23,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
18
23
  'modal.split-picker': ['navigation', 'terminal-input'],
19
24
  'modal.theme-picker.filtering': ['navigation'],
20
25
  'modal.update-available': ['navigation'],
26
+ 'modal.worktree-delete-confirm': ['navigation'],
21
27
  'modal.worktree-move': ['git-mode', 'navigation'],
22
28
  'navigation': [
23
29
  'terminal-input',
@@ -30,6 +36,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
30
36
  'modal.rename-tab',
31
37
  'modal.update-available',
32
38
  'modal.ai-usage',
39
+ 'modal.worktree-delete-confirm',
33
40
  'git-mode',
34
41
  ],
35
42
  'terminal-input': ['navigation', 'modal.split-picker', 'modal.ai-usage'],
@@ -9,6 +9,8 @@ export type ModeId =
9
9
  | 'git-mode'
10
10
  | 'modal.new-tab.command-edit'
11
11
  | 'modal.new-tab.editing-command'
12
+ | 'modal.new-tab.worktree-delete-confirm'
13
+ | 'modal.worktree-delete-confirm'
12
14
  | 'modal.session-picker.filtering'
13
15
  | 'modal.session-name'
14
16
  | 'modal.create-session'
@@ -28,6 +30,7 @@ export type ModeId =
28
30
  export type SideEffect =
29
31
  | { type: 'quit'; state: AppState }
30
32
  | { type: 'launch-selected-assistant' }
33
+ | { type: 'load-new-tab-base-branches' }
31
34
  | { type: 'edit-selected-assistant' }
32
35
  | { type: 'confirm-selected-session' }
33
36
  | { type: 'delete-selected-session' }
@@ -71,7 +74,18 @@ export type SideEffect =
71
74
  | { type: 'cycle-sidebar-item'; direction: 1 | -1 }
72
75
  | { type: 'switch-tab-by-index'; index: number }
73
76
  | { type: 'delete-session'; sessionId: string }
74
- | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
77
+ | {
78
+ type: 'delete-worktree'
79
+ sessionId: string
80
+ worktreeId: string
81
+ // Force the git worktree removal (discards uncommitted changes in the
82
+ // worktree). Also implies closing the worktree's tabs.
83
+ force?: boolean
84
+ // Close the worktree's tabs without forcing the git removal. Lets the
85
+ // sidebar "Remove worktree" clean up tabs (avoiding orphans) while still
86
+ // refusing to discard uncommitted work in a temp worktree.
87
+ closeTabs?: boolean
88
+ }
75
89
  | {
76
90
  type: 'move-worktree'
77
91
  sessionId: string
@@ -6,7 +6,14 @@ import { collectHelpEntries } from '../../input/keymap/help-entries'
6
6
  import { getActiveKeymap } from '../../input/keymap/keymap-ref'
7
7
  import { getAllAssistantOptions } from '../../pty/command-registry'
8
8
  import { filterThemeIds } from '../../ui/filter-themes'
9
- import { filterAssistants, filterSessions, filterSnippets } from '../selectors'
9
+ import {
10
+ type BaseRefOption,
11
+ buildBaseRefOptions,
12
+ filterAssistants,
13
+ filterSessions,
14
+ filterSnippets,
15
+ getTemplateNoneOffset,
16
+ } from '../selectors'
10
17
  import { reduceAutoCommitState } from './auto-commit-state'
11
18
 
12
19
  function emptyModal() {
@@ -39,6 +46,17 @@ function getCurrentWorktreeIndex(state: AppState): number {
39
46
  )
40
47
  }
41
48
 
49
+ function getNewTabBaseOptions(state: AppState, queryOverride?: string): BaseRefOption[] {
50
+ if (state.modal.type !== 'new-tab') return []
51
+ const worktrees =
52
+ state.sessions.find((entry) => entry.id === state.currentSessionId)?.worktrees ?? []
53
+ return buildBaseRefOptions(
54
+ worktrees,
55
+ state.modal.baseBranches,
56
+ queryOverride ?? state.modal.baseQuery
57
+ )
58
+ }
59
+
42
60
  function getSelectedNewTabAssistant(state: AppState, assistantId?: string) {
43
61
  if (assistantId != null && assistantId !== '') {
44
62
  return getAllAssistantOptions(state.customCommands).find((entry) => entry.id === assistantId)
@@ -58,6 +76,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
58
76
  focusMode: 'command-edit',
59
77
  modal: {
60
78
  activeField: 'assistant',
79
+ baseBranches: [],
80
+ baseQuery: '',
81
+ baseRef: '',
61
82
  branchError: null,
62
83
  branchName: '',
63
84
  createWorktree: false,
@@ -70,19 +91,25 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
70
91
  step: 'assistant',
71
92
  targetWorktreeIndex,
72
93
  type: 'new-tab',
73
- worktreeDeleteConfirmId: null,
74
- worktreeDeleteMessage: null,
94
+ worktreeDeletePrompt: null,
75
95
  worktreeName: '',
76
96
  },
77
97
  }
78
98
  }
79
99
  case 'enter-new-tab-worktree-create': {
80
100
  if (state.modal.type !== 'new-tab') return state
101
+ // Default the base to the branch of the worktree we're forking from (the
102
+ // current target), preserving the previous always-fork-from-source
103
+ // behaviour until the user picks another base.
104
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
105
+ const sourceWorktree = session?.worktrees?.[state.modal.targetWorktreeIndex]
81
106
  return {
82
107
  ...state,
83
108
  modal: {
84
109
  ...state.modal,
85
110
  activeField: 'worktree-name',
111
+ baseQuery: '',
112
+ baseRef: sourceWorktree?.branch ?? '',
86
113
  createWorktree: true,
87
114
  cursorPos: state.modal.worktreeName.length,
88
115
  selectedIndex: 0,
@@ -90,14 +117,55 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
90
117
  },
91
118
  }
92
119
  }
93
- case 'set-new-tab-worktree-delete-state': {
120
+ case 'set-new-tab-base-branches': {
121
+ if (state.modal.type !== 'new-tab') return state
122
+ const modalWithBranches = { ...state.modal, baseBranches: action.branches }
123
+ const withBranches: AppState = { ...state, modal: modalWithBranches }
124
+ // Backfill a default base if none resolved yet (e.g. detached source).
125
+ if (state.modal.baseRef !== '') return withBranches
126
+ const firstOption = getNewTabBaseOptions(withBranches)[0]
127
+ return {
128
+ ...withBranches,
129
+ modal: { ...modalWithBranches, baseRef: firstOption?.ref ?? '' },
130
+ }
131
+ }
132
+ case 'enter-new-tab-template-pick': {
94
133
  if (state.modal.type !== 'new-tab') return state
95
134
  return {
96
135
  ...state,
97
136
  modal: {
98
137
  ...state.modal,
99
- worktreeDeleteConfirmId: action.confirmWorktreeId ?? null,
100
- worktreeDeleteMessage: action.message,
138
+ activeField: 'target-worktree',
139
+ cursorPos: 0,
140
+ selectedIndex: 0,
141
+ step: 'template',
142
+ },
143
+ }
144
+ }
145
+ case 'enter-new-tab-template-shortcut': {
146
+ if (state.modal.type !== 'new-tab') return state
147
+ const defaultName = state.modal.worktreeName || 'wt-template'
148
+ return {
149
+ ...state,
150
+ modal: {
151
+ ...state.modal,
152
+ activeField: 'worktree-name',
153
+ createWorktree: true,
154
+ cursorPos: defaultName.length,
155
+ selectedAssistantId: null,
156
+ selectedIndex: 0,
157
+ step: 'worktree-create',
158
+ worktreeName: defaultName,
159
+ },
160
+ }
161
+ }
162
+ case 'set-new-tab-worktree-delete-prompt': {
163
+ if (state.modal.type !== 'new-tab') return state
164
+ return {
165
+ ...state,
166
+ modal: {
167
+ ...state.modal,
168
+ worktreeDeletePrompt: action.prompt,
101
169
  },
102
170
  }
103
171
  }
@@ -115,6 +183,32 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
115
183
  }
116
184
  case 'select-new-tab-assistant': {
117
185
  if (state.modal.type !== 'new-tab' || state.modal.editingCommand !== null) return state
186
+ if (
187
+ action.assistantId === undefined &&
188
+ state.modal.step === 'assistant' &&
189
+ state.worktreeTemplates.length > 0
190
+ ) {
191
+ const filtered = filterAssistants(
192
+ getAllAssistantOptions(state.customCommands),
193
+ state.modal.editBuffer
194
+ )
195
+ if (state.modal.selectedIndex >= filtered.length) {
196
+ const defaultName = state.modal.worktreeName || 'wt-template'
197
+ return {
198
+ ...state,
199
+ modal: {
200
+ ...state.modal,
201
+ activeField: 'worktree-name',
202
+ createWorktree: true,
203
+ cursorPos: defaultName.length,
204
+ selectedAssistantId: null,
205
+ selectedIndex: 0,
206
+ step: 'worktree-create',
207
+ worktreeName: defaultName,
208
+ },
209
+ }
210
+ }
211
+ }
118
212
  const option = getSelectedNewTabAssistant(state, action.assistantId)
119
213
  if (!option) return state
120
214
  const targetWorktreeIndex = getCurrentWorktreeIndex(state)
@@ -131,8 +225,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
131
225
  selectedIndex: worktreeCount === 0 ? 0 : targetWorktreeIndex,
132
226
  step: 'worktree',
133
227
  targetWorktreeIndex,
134
- worktreeDeleteConfirmId: null,
135
- worktreeDeleteMessage: null,
228
+ worktreeDeletePrompt: null,
136
229
  worktreeName: state.modal.worktreeName || `wt-${option.label}`,
137
230
  },
138
231
  }
@@ -167,8 +260,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
167
260
  : Math.min(state.modal.selectedIndex, Math.max(0, worktreeCount - 1)),
168
261
  step: option && createWorktree ? 'worktree' : state.modal.step,
169
262
  targetWorktreeIndex,
170
- worktreeDeleteConfirmId: null,
171
- worktreeDeleteMessage: null,
263
+ worktreeDeletePrompt: null,
172
264
  worktreeName: defaultName,
173
265
  },
174
266
  }
@@ -223,6 +315,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
223
315
  modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
224
316
  }
225
317
  }
318
+ case 'open-worktree-delete-confirm': {
319
+ return {
320
+ ...state,
321
+ focusMode: 'modal',
322
+ modal: {
323
+ closeTabs: action.closeTabs,
324
+ editBuffer: null,
325
+ force: action.force,
326
+ reason: action.reason,
327
+ selectedIndex: 0,
328
+ sessionId: action.sessionId,
329
+ sessionTargetId: null,
330
+ type: 'worktree-delete-confirm',
331
+ worktreeId: action.worktreeId,
332
+ worktreeLabel: action.worktreeLabel,
333
+ },
334
+ }
335
+ }
226
336
  case 'open-help-modal': {
227
337
  const keymap = getActiveKeymap()
228
338
  const scope = action.scope ?? null
@@ -555,18 +665,31 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
555
665
  return state
556
666
  }
557
667
  if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
558
- return state
668
+ if (state.modal.activeField !== 'base') return state
669
+ const options = getNewTabBaseOptions(state)
670
+ if (options.length === 0) return state
671
+ const next = (state.modal.selectedIndex + action.delta + options.length) % options.length
672
+ return {
673
+ ...state,
674
+ modal: {
675
+ ...state.modal,
676
+ baseRef: options[next]?.ref ?? state.modal.baseRef,
677
+ selectedIndex: next,
678
+ },
679
+ }
559
680
  }
560
681
  let optionCount: number
561
682
  if (state.modal.type === 'new-tab') {
562
683
  if (state.modal.step === 'worktree') {
563
684
  if (state.modal.activeField === 'worktree-name') return state
564
685
  optionCount = getCurrentWorktreeCount(state) + 1
686
+ } else if (state.modal.step === 'template') {
687
+ optionCount =
688
+ state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
565
689
  } else {
566
- optionCount = filterAssistants(
567
- getAllAssistantOptions(state.customCommands),
568
- state.modal.editBuffer
569
- ).length
690
+ optionCount =
691
+ filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
692
+ .length + (state.worktreeTemplates.length > 0 ? 1 : 0)
570
693
  }
571
694
  } else if (state.modal.type === 'split-picker') {
572
695
  optionCount = getAllAssistantOptions(state.customCommands).length
@@ -598,8 +721,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
598
721
  createWorktree:
599
722
  (state.modal.selectedIndex + action.delta + optionCount) % optionCount ===
600
723
  optionCount - 1,
601
- worktreeDeleteConfirmId: null,
602
- worktreeDeleteMessage: null,
724
+ worktreeDeletePrompt: null,
603
725
  }
604
726
  : null),
605
727
  },
@@ -620,17 +742,34 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
620
742
  return state
621
743
  }
622
744
  if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
623
- return state
745
+ if (state.modal.activeField !== 'base') return state
746
+ const options = getNewTabBaseOptions(state)
747
+ if (options.length === 0) return state
748
+ const clamped = Math.max(0, Math.min(options.length - 1, action.index))
749
+ if (clamped === state.modal.selectedIndex) return state
750
+ return {
751
+ ...state,
752
+ modal: {
753
+ ...state.modal,
754
+ baseRef: options[clamped]?.ref ?? state.modal.baseRef,
755
+ selectedIndex: clamped,
756
+ },
757
+ }
624
758
  }
625
759
  let optionCount: number
626
760
  if (state.modal.type === 'help') {
627
761
  optionCount = state.modal.entryCount
628
762
  } else if (state.modal.type === 'new-tab') {
629
- optionCount =
630
- state.modal.step === 'worktree'
631
- ? getCurrentWorktreeCount(state) + 1
632
- : filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
633
- .length
763
+ if (state.modal.step === 'worktree') {
764
+ optionCount = getCurrentWorktreeCount(state) + 1
765
+ } else if (state.modal.step === 'template') {
766
+ optionCount =
767
+ state.worktreeTemplates.length + getTemplateNoneOffset(state.modal.selectedAssistantId)
768
+ } else {
769
+ optionCount =
770
+ filterAssistants(getAllAssistantOptions(state.customCommands), state.modal.editBuffer)
771
+ .length + (state.worktreeTemplates.length > 0 ? 1 : 0)
772
+ }
634
773
  } else if (state.modal.type === 'split-picker') {
635
774
  optionCount = getAllAssistantOptions(state.customCommands).length
636
775
  } else if (state.modal.type === 'create-session') {
@@ -656,8 +795,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
656
795
  ...state.modal,
657
796
  createWorktree: clamped === optionCount - 1,
658
797
  selectedIndex: clamped,
659
- worktreeDeleteConfirmId: null,
660
- worktreeDeleteMessage: null,
798
+ worktreeDeletePrompt: null,
661
799
  },
662
800
  }
663
801
  }
@@ -677,8 +815,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
677
815
  if (
678
816
  state.modal.type === 'new-tab' &&
679
817
  state.modal.editingCommand === null &&
680
- state.modal.step === 'worktree' &&
681
- state.modal.activeField === 'target-worktree'
818
+ (state.modal.step === 'template' ||
819
+ (state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
682
820
  ) {
683
821
  return state
684
822
  }
@@ -702,13 +840,15 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
702
840
  buffer = state.modal.worktreeName
703
841
  } else if (state.modal.activeField === 'branch-name') {
704
842
  buffer = state.modal.branchName
843
+ } else if (state.modal.activeField === 'base') {
844
+ buffer = state.modal.baseQuery
705
845
  }
706
846
  }
707
847
  if (
708
848
  state.modal.type === 'new-tab' &&
709
849
  state.modal.editingCommand === null &&
710
- state.modal.step === 'worktree' &&
711
- state.modal.activeField === 'target-worktree'
850
+ (state.modal.step === 'template' ||
851
+ (state.modal.step === 'worktree' && state.modal.activeField === 'target-worktree'))
712
852
  ) {
713
853
  return state
714
854
  }
@@ -753,6 +893,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
753
893
  },
754
894
  }
755
895
  }
896
+ if (
897
+ state.modal.type === 'new-tab' &&
898
+ state.modal.editingCommand === null &&
899
+ state.modal.activeField === 'base'
900
+ ) {
901
+ // Re-filter and snap the selection/base to the top match as the query changes.
902
+ const topOption = getNewTabBaseOptions(state, nextBuffer)[0]
903
+ return {
904
+ ...state,
905
+ modal: {
906
+ ...state.modal,
907
+ baseQuery: nextBuffer,
908
+ baseRef: topOption?.ref ?? state.modal.baseRef,
909
+ cursorPos: nextCursor,
910
+ selectedIndex: 0,
911
+ },
912
+ }
913
+ }
756
914
  const resetIndex =
757
915
  !isNewTabEditing &&
758
916
  (state.modal.type === 'session-picker' ||
@@ -785,6 +943,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
785
943
  }
786
944
  }
787
945
  if (state.modal.type === 'new-tab') {
946
+ if (state.modal.step === 'template') {
947
+ return {
948
+ ...state,
949
+ modal: {
950
+ ...state.modal,
951
+ activeField: 'worktree-name',
952
+ cursorPos: state.modal.worktreeName.length,
953
+ selectedIndex: 0,
954
+ step: 'worktree-create',
955
+ },
956
+ }
957
+ }
788
958
  if (state.modal.step === 'worktree-create') {
789
959
  const optionCount = getCurrentWorktreeCount(state) + 1
790
960
  return {
@@ -821,8 +991,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
821
991
  cursorPos: state.modal.editBuffer?.length ?? 0,
822
992
  selectedIndex: Math.max(0, assistantIndex),
823
993
  step: 'assistant',
824
- worktreeDeleteConfirmId: null,
825
- worktreeDeleteMessage: null,
994
+ worktreeDeletePrompt: null,
826
995
  },
827
996
  }
828
997
  }
@@ -911,8 +1080,38 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
911
1080
  if (state.modal.type === 'new-tab') {
912
1081
  if (state.modal.step === 'assistant') return state
913
1082
  if (state.modal.step === 'worktree-create') {
914
- const nextField: typeof state.modal.activeField =
915
- state.modal.activeField === 'worktree-name' ? 'branch-name' : 'worktree-name'
1083
+ const cycle: Record<
1084
+ 'worktree-name' | 'branch-name' | 'base',
1085
+ typeof state.modal.activeField
1086
+ > = {
1087
+ 'base': 'worktree-name',
1088
+ 'branch-name': 'base',
1089
+ 'worktree-name': 'branch-name',
1090
+ }
1091
+ const nextField =
1092
+ state.modal.activeField === 'worktree-name' ||
1093
+ state.modal.activeField === 'branch-name' ||
1094
+ state.modal.activeField === 'base'
1095
+ ? cycle[state.modal.activeField]
1096
+ : 'worktree-name'
1097
+ if (nextField === 'base') {
1098
+ // Highlight the row matching the resolved base ref when entering it.
1099
+ const currentBaseRef = state.modal.baseRef
1100
+ const options = getNewTabBaseOptions(state)
1101
+ const baseIndex = Math.max(
1102
+ 0,
1103
+ options.findIndex((option) => option.ref === currentBaseRef)
1104
+ )
1105
+ return {
1106
+ ...state,
1107
+ modal: {
1108
+ ...state.modal,
1109
+ activeField: nextField,
1110
+ cursorPos: state.modal.baseQuery.length,
1111
+ selectedIndex: baseIndex,
1112
+ },
1113
+ }
1114
+ }
916
1115
  const nextValue =
917
1116
  nextField === 'branch-name' ? state.modal.branchName : state.modal.worktreeName
918
1117
  return {
@@ -18,15 +18,23 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
18
18
  const snapshot =
19
19
  action.workspaceSnapshot ??
20
20
  state.sessions.find((entry) => entry.id === action.sessionId)?.workspaceSnapshot
21
- const restored = restoreWorkspaceState(state, snapshot, {
22
- forceDisconnected: action.forceDisconnected ?? true,
23
- })
24
21
  // The session's activeWorktreeId may have been patched right before
25
22
  // this load (e.g. the cross-workspace branch of handleCycleSidebarItem
26
23
  // sets it to the worktree the user just clicked). The snapshot's
27
24
  // activeTabId still reflects the *last* worktree they were on, so
28
25
  // honor the patched worktree by filtering the restored tab list.
29
26
  const loadedSession = state.sessions.find((entry) => entry.id === action.sessionId)
27
+ const loadedWorktrees = loadedSession?.worktrees ?? []
28
+ const restored = restoreWorkspaceState(state, snapshot, {
29
+ forceDisconnected: action.forceDisconnected ?? true,
30
+ // Drop tabs bound to worktrees this session no longer owns so a stale
31
+ // id can't be caught by a later worktree delete (closing "another
32
+ // worktree's" tabs) and so corrupted catalogs self-heal on load. Only
33
+ // prune once the session has worktrees — an empty list means it hasn't
34
+ // been initialized yet, and pruning then would wrongly drop bound tabs.
35
+ validWorktreeIds:
36
+ loadedWorktrees.length > 0 ? new Set(loadedWorktrees.map((w) => w.id)) : undefined,
37
+ })
30
38
  const visible = filterTabsForActiveWorktree(restored.tabs, loadedSession)
31
39
  // Prefer the snapshot's tab; if it isn't visible under the active
32
40
  // worktree (e.g. a multi-worktree session restored onto a different
@@ -188,22 +196,6 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
188
196
  : withActiveWorktree(next, action.worktree.id)
189
197
  }),
190
198
  }
191
- case 'remove-worktree-record':
192
- return {
193
- ...state,
194
- sessions: state.sessions.map((session) => {
195
- if (session.id !== action.sessionId) return session
196
- const remaining = (session.worktrees ?? []).filter((w) => w.id !== action.worktreeId)
197
- if (remaining.length === 0) return session
198
- if (session.activeWorktreeId !== action.worktreeId) {
199
- return { ...session, updatedAt: new Date().toISOString(), worktrees: remaining }
200
- }
201
- return withActiveWorktree(
202
- { ...session, activeWorktreeId: remaining[0]?.id, worktrees: remaining },
203
- remaining[0]?.id ?? ''
204
- )
205
- }),
206
- }
207
199
  case 'set-active-worktree': {
208
200
  const sessions = state.sessions.map((session) =>
209
201
  session.id === action.sessionId ? withActiveWorktree(session, action.worktreeId) : session
@@ -469,6 +469,23 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
469
469
  }
470
470
  return { ...state, tabs }
471
471
  }
472
+ case 'reorder-tabs': {
473
+ // Drag-and-drop reorder of the visible tab strip. `orderedTabIds` is the
474
+ // flattened new order of the currently-visible tabs (group members already
475
+ // expanded into contiguous runs). We only rewrite the slots those tabs
476
+ // occupy in `state.tabs`, leaving tabs from other worktrees anchored in
477
+ // place. Because group members arrive contiguous, splits stay intact.
478
+ const visibleSet = new Set(action.orderedTabIds)
479
+ const byId = new Map(state.tabs.map((tab) => [tab.id, tab]))
480
+ let cursor = 0
481
+ const tabs = state.tabs.map((tab) => {
482
+ if (!visibleSet.has(tab.id)) return tab
483
+ const nextId = action.orderedTabIds[cursor]
484
+ cursor++
485
+ return (nextId != null ? byId.get(nextId) : undefined) ?? tab
486
+ })
487
+ return { ...state, tabs }
488
+ }
472
489
  case 'reset-tab-session':
473
490
  return withActiveTabWorktree(
474
491
  {