@brimveyn/aimux 1.14.15 → 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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +71 -33
- package/src/app.tsx +26 -1
- package/src/config.ts +4 -0
- package/src/git/worktree.ts +45 -0
- package/src/input/modes/bridge.ts +4 -0
- package/src/input/modes/transitions.ts +8 -1
- package/src/input/modes/types.ts +15 -1
- package/src/state/reducers/modal-state.ts +138 -19
- package/src/state/reducers/session-state.ts +11 -19
- package/src/state/reducers/tab-state.ts +17 -0
- package/src/state/selectors.ts +45 -1
- package/src/state/session-persistence.ts +23 -1
- package/src/state/types.ts +39 -7
- package/src/ui/components/layout/sidebar/worktree-row.tsx +12 -2
- package/src/ui/components/layout/top-tab-bar.tsx +146 -12
- package/src/ui/components/modals/shared/worktree-delete-confirm.tsx +39 -0
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +70 -23
- package/src/ui/root.tsx +14 -2
|
@@ -7,6 +7,8 @@ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
|
|
|
7
7
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
8
8
|
import { filterThemeIds } from '../../ui/filter-themes'
|
|
9
9
|
import {
|
|
10
|
+
type BaseRefOption,
|
|
11
|
+
buildBaseRefOptions,
|
|
10
12
|
filterAssistants,
|
|
11
13
|
filterSessions,
|
|
12
14
|
filterSnippets,
|
|
@@ -44,6 +46,17 @@ function getCurrentWorktreeIndex(state: AppState): number {
|
|
|
44
46
|
)
|
|
45
47
|
}
|
|
46
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
|
+
|
|
47
60
|
function getSelectedNewTabAssistant(state: AppState, assistantId?: string) {
|
|
48
61
|
if (assistantId != null && assistantId !== '') {
|
|
49
62
|
return getAllAssistantOptions(state.customCommands).find((entry) => entry.id === assistantId)
|
|
@@ -63,6 +76,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
63
76
|
focusMode: 'command-edit',
|
|
64
77
|
modal: {
|
|
65
78
|
activeField: 'assistant',
|
|
79
|
+
baseBranches: [],
|
|
80
|
+
baseQuery: '',
|
|
81
|
+
baseRef: '',
|
|
66
82
|
branchError: null,
|
|
67
83
|
branchName: '',
|
|
68
84
|
createWorktree: false,
|
|
@@ -75,19 +91,25 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
75
91
|
step: 'assistant',
|
|
76
92
|
targetWorktreeIndex,
|
|
77
93
|
type: 'new-tab',
|
|
78
|
-
|
|
79
|
-
worktreeDeleteMessage: null,
|
|
94
|
+
worktreeDeletePrompt: null,
|
|
80
95
|
worktreeName: '',
|
|
81
96
|
},
|
|
82
97
|
}
|
|
83
98
|
}
|
|
84
99
|
case 'enter-new-tab-worktree-create': {
|
|
85
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]
|
|
86
106
|
return {
|
|
87
107
|
...state,
|
|
88
108
|
modal: {
|
|
89
109
|
...state.modal,
|
|
90
110
|
activeField: 'worktree-name',
|
|
111
|
+
baseQuery: '',
|
|
112
|
+
baseRef: sourceWorktree?.branch ?? '',
|
|
91
113
|
createWorktree: true,
|
|
92
114
|
cursorPos: state.modal.worktreeName.length,
|
|
93
115
|
selectedIndex: 0,
|
|
@@ -95,6 +117,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
95
117
|
},
|
|
96
118
|
}
|
|
97
119
|
}
|
|
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
|
+
}
|
|
98
132
|
case 'enter-new-tab-template-pick': {
|
|
99
133
|
if (state.modal.type !== 'new-tab') return state
|
|
100
134
|
return {
|
|
@@ -125,14 +159,13 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
125
159
|
},
|
|
126
160
|
}
|
|
127
161
|
}
|
|
128
|
-
case 'set-new-tab-worktree-delete-
|
|
162
|
+
case 'set-new-tab-worktree-delete-prompt': {
|
|
129
163
|
if (state.modal.type !== 'new-tab') return state
|
|
130
164
|
return {
|
|
131
165
|
...state,
|
|
132
166
|
modal: {
|
|
133
167
|
...state.modal,
|
|
134
|
-
|
|
135
|
-
worktreeDeleteMessage: action.message,
|
|
168
|
+
worktreeDeletePrompt: action.prompt,
|
|
136
169
|
},
|
|
137
170
|
}
|
|
138
171
|
}
|
|
@@ -192,8 +225,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
192
225
|
selectedIndex: worktreeCount === 0 ? 0 : targetWorktreeIndex,
|
|
193
226
|
step: 'worktree',
|
|
194
227
|
targetWorktreeIndex,
|
|
195
|
-
|
|
196
|
-
worktreeDeleteMessage: null,
|
|
228
|
+
worktreeDeletePrompt: null,
|
|
197
229
|
worktreeName: state.modal.worktreeName || `wt-${option.label}`,
|
|
198
230
|
},
|
|
199
231
|
}
|
|
@@ -228,8 +260,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
228
260
|
: Math.min(state.modal.selectedIndex, Math.max(0, worktreeCount - 1)),
|
|
229
261
|
step: option && createWorktree ? 'worktree' : state.modal.step,
|
|
230
262
|
targetWorktreeIndex,
|
|
231
|
-
|
|
232
|
-
worktreeDeleteMessage: null,
|
|
263
|
+
worktreeDeletePrompt: null,
|
|
233
264
|
worktreeName: defaultName,
|
|
234
265
|
},
|
|
235
266
|
}
|
|
@@ -284,6 +315,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
284
315
|
modal: { ...state.modal, deleteSource: !state.modal.deleteSource },
|
|
285
316
|
}
|
|
286
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
|
+
}
|
|
287
336
|
case 'open-help-modal': {
|
|
288
337
|
const keymap = getActiveKeymap()
|
|
289
338
|
const scope = action.scope ?? null
|
|
@@ -616,7 +665,18 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
616
665
|
return state
|
|
617
666
|
}
|
|
618
667
|
if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
|
|
619
|
-
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
|
+
}
|
|
620
680
|
}
|
|
621
681
|
let optionCount: number
|
|
622
682
|
if (state.modal.type === 'new-tab') {
|
|
@@ -661,8 +721,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
661
721
|
createWorktree:
|
|
662
722
|
(state.modal.selectedIndex + action.delta + optionCount) % optionCount ===
|
|
663
723
|
optionCount - 1,
|
|
664
|
-
|
|
665
|
-
worktreeDeleteMessage: null,
|
|
724
|
+
worktreeDeletePrompt: null,
|
|
666
725
|
}
|
|
667
726
|
: null),
|
|
668
727
|
},
|
|
@@ -683,7 +742,19 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
683
742
|
return state
|
|
684
743
|
}
|
|
685
744
|
if (state.modal.type === 'new-tab' && state.modal.step === 'worktree-create') {
|
|
686
|
-
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
|
+
}
|
|
687
758
|
}
|
|
688
759
|
let optionCount: number
|
|
689
760
|
if (state.modal.type === 'help') {
|
|
@@ -724,8 +795,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
724
795
|
...state.modal,
|
|
725
796
|
createWorktree: clamped === optionCount - 1,
|
|
726
797
|
selectedIndex: clamped,
|
|
727
|
-
|
|
728
|
-
worktreeDeleteMessage: null,
|
|
798
|
+
worktreeDeletePrompt: null,
|
|
729
799
|
},
|
|
730
800
|
}
|
|
731
801
|
}
|
|
@@ -770,6 +840,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
770
840
|
buffer = state.modal.worktreeName
|
|
771
841
|
} else if (state.modal.activeField === 'branch-name') {
|
|
772
842
|
buffer = state.modal.branchName
|
|
843
|
+
} else if (state.modal.activeField === 'base') {
|
|
844
|
+
buffer = state.modal.baseQuery
|
|
773
845
|
}
|
|
774
846
|
}
|
|
775
847
|
if (
|
|
@@ -821,6 +893,24 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
821
893
|
},
|
|
822
894
|
}
|
|
823
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
|
+
}
|
|
824
914
|
const resetIndex =
|
|
825
915
|
!isNewTabEditing &&
|
|
826
916
|
(state.modal.type === 'session-picker' ||
|
|
@@ -901,8 +991,7 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
901
991
|
cursorPos: state.modal.editBuffer?.length ?? 0,
|
|
902
992
|
selectedIndex: Math.max(0, assistantIndex),
|
|
903
993
|
step: 'assistant',
|
|
904
|
-
|
|
905
|
-
worktreeDeleteMessage: null,
|
|
994
|
+
worktreeDeletePrompt: null,
|
|
906
995
|
},
|
|
907
996
|
}
|
|
908
997
|
}
|
|
@@ -991,8 +1080,38 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
|
|
|
991
1080
|
if (state.modal.type === 'new-tab') {
|
|
992
1081
|
if (state.modal.step === 'assistant') return state
|
|
993
1082
|
if (state.modal.step === 'worktree-create') {
|
|
994
|
-
const
|
|
995
|
-
|
|
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
|
+
}
|
|
996
1115
|
const nextValue =
|
|
997
1116
|
nextField === 'branch-name' ? state.modal.branchName : state.modal.worktreeName
|
|
998
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
|
{
|
package/src/state/selectors.ts
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
import type { AssistantOption } from '../pty/command-registry'
|
|
2
|
-
import type { AssistantId, SessionRecord, SnippetRecord } from './types'
|
|
2
|
+
import type { AssistantId, SessionRecord, SnippetRecord, WorktreeRecord } from './types'
|
|
3
|
+
|
|
4
|
+
export interface BaseRefOption {
|
|
5
|
+
/** Git ref the new worktree is forked from. */
|
|
6
|
+
ref: string
|
|
7
|
+
label: string
|
|
8
|
+
kind: 'worktree' | 'branch'
|
|
9
|
+
/** Worktree name, for the 'worktree' kind. */
|
|
10
|
+
detail?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Ordered, filtered base-ref candidates for the worktree-create "Base" picker:
|
|
15
|
+
* branches checked out in the session's worktrees first (labelled with the
|
|
16
|
+
* worktree name), then the remaining local branches. A branch already surfaced
|
|
17
|
+
* via a worktree is not repeated. Throwaway `aimux/` branches are skipped unless
|
|
18
|
+
* a live worktree is on them — `git worktree remove` leaves the branch behind,
|
|
19
|
+
* so deleted temp worktrees would otherwise haunt the list as orphan branches.
|
|
20
|
+
*/
|
|
21
|
+
export function buildBaseRefOptions(
|
|
22
|
+
worktrees: WorktreeRecord[],
|
|
23
|
+
localBranches: string[],
|
|
24
|
+
query: string
|
|
25
|
+
): BaseRefOption[] {
|
|
26
|
+
const seen = new Set<string>()
|
|
27
|
+
const options: BaseRefOption[] = []
|
|
28
|
+
for (const worktree of worktrees) {
|
|
29
|
+
if (worktree.branch == null || worktree.branch === '' || seen.has(worktree.branch)) continue
|
|
30
|
+
seen.add(worktree.branch)
|
|
31
|
+
options.push({
|
|
32
|
+
detail: worktree.name,
|
|
33
|
+
kind: 'worktree',
|
|
34
|
+
label: worktree.branch,
|
|
35
|
+
ref: worktree.branch,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
for (const branch of localBranches) {
|
|
39
|
+
if (seen.has(branch) || branch.startsWith('aimux/')) continue
|
|
40
|
+
seen.add(branch)
|
|
41
|
+
options.push({ kind: 'branch', label: branch, ref: branch })
|
|
42
|
+
}
|
|
43
|
+
const trimmed = query.trim().toLowerCase()
|
|
44
|
+
if (trimmed === '') return options
|
|
45
|
+
return options.filter((option) => option.label.toLowerCase().includes(trimmed))
|
|
46
|
+
}
|
|
3
47
|
|
|
4
48
|
/**
|
|
5
49
|
* 0 when the template picker should NOT show the "None" fallback (no assistant
|
|
@@ -72,6 +72,27 @@ export interface RestoreOptions {
|
|
|
72
72
|
// will overwrite the status with daemon truth — leaving the flag on would
|
|
73
73
|
// briefly flash the "Restored snapshot" hint on every j/k cycle.
|
|
74
74
|
forceDisconnected?: boolean
|
|
75
|
+
// When provided, drop tabs pinned to a worktree id that the session no
|
|
76
|
+
// longer owns. Some delete paths (notably the sidebar's "Remove worktree")
|
|
77
|
+
// historically removed the worktree record without closing its tabs, leaving
|
|
78
|
+
// orphans bound to a vanished id. Those orphans are invisible (filtered out
|
|
79
|
+
// by the active-worktree filter) yet keep a worktree id that a *future*
|
|
80
|
+
// delete can collide with — exactly what closes "another worktree's" tabs.
|
|
81
|
+
// Pruning them on restore both repairs corrupted catalogs and prevents the
|
|
82
|
+
// collision. Tabs with no worktree id (legacy/unbound) are always kept.
|
|
83
|
+
validWorktreeIds?: ReadonlySet<string>
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Drop tabs bound to a worktree id the session no longer owns. Unbound tabs
|
|
87
|
+
// (no worktreeId) are kept — they surface under the primary worktree.
|
|
88
|
+
function pruneOrphanedTabs(
|
|
89
|
+
tabs: TabSession[],
|
|
90
|
+
validWorktreeIds: ReadonlySet<string> | undefined
|
|
91
|
+
): TabSession[] {
|
|
92
|
+
if (!validWorktreeIds) return tabs
|
|
93
|
+
return tabs.filter(
|
|
94
|
+
(tab) => tab.worktreeId == null || tab.worktreeId === '' || validWorktreeIds.has(tab.worktreeId)
|
|
95
|
+
)
|
|
75
96
|
}
|
|
76
97
|
|
|
77
98
|
export function restoreTabsFromWorkspace(
|
|
@@ -84,7 +105,7 @@ export function restoreTabsFromWorkspace(
|
|
|
84
105
|
|
|
85
106
|
const forceDisconnected = options.forceDisconnected ?? true
|
|
86
107
|
|
|
87
|
-
|
|
108
|
+
const restored: TabSession[] = snapshot.tabs
|
|
88
109
|
.filter(
|
|
89
110
|
(tab): tab is typeof tab & { status: Exclude<typeof tab.status, 'exited'> } =>
|
|
90
111
|
tab.status !== 'exited'
|
|
@@ -103,6 +124,7 @@ export function restoreTabsFromWorkspace(
|
|
|
103
124
|
viewport: tab.viewport,
|
|
104
125
|
worktreeId: tab.worktreeId,
|
|
105
126
|
}))
|
|
127
|
+
return pruneOrphanedTabs(restored, options.validWorktreeIds)
|
|
106
128
|
}
|
|
107
129
|
|
|
108
130
|
export function restoreLayoutTrees(
|
package/src/state/types.ts
CHANGED
|
@@ -47,6 +47,7 @@ export type ModalType =
|
|
|
47
47
|
| 'update-available'
|
|
48
48
|
| 'ai-usage'
|
|
49
49
|
| 'worktree-move'
|
|
50
|
+
| 'worktree-delete-confirm'
|
|
50
51
|
| null
|
|
51
52
|
|
|
52
53
|
export interface TerminalSpan {
|
|
@@ -323,16 +324,21 @@ export interface ModalClosed extends ModalBase {
|
|
|
323
324
|
export interface ModalNewTab extends ModalBase {
|
|
324
325
|
type: 'new-tab'
|
|
325
326
|
editingCommand: AssistantId | null
|
|
326
|
-
activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name'
|
|
327
|
+
activeField: 'assistant' | 'branch-name' | 'target-worktree' | 'worktree-name' | 'base'
|
|
327
328
|
branchError: string | null
|
|
328
329
|
branchName: string
|
|
329
330
|
createWorktree: boolean
|
|
330
331
|
selectedAssistantId: AssistantId | null
|
|
331
332
|
step: 'assistant' | 'worktree' | 'worktree-create' | 'template'
|
|
332
333
|
targetWorktreeIndex: number
|
|
333
|
-
|
|
334
|
-
worktreeDeleteMessage: string | null
|
|
334
|
+
worktreeDeletePrompt: { worktreeId: string; reason: string } | null
|
|
335
335
|
worktreeName: string
|
|
336
|
+
/** Filter text typed into the "Base" picker on the worktree-create step. */
|
|
337
|
+
baseQuery: string
|
|
338
|
+
/** Resolved base ref the new worktree is forked from (branch of a worktree or a local branch). */
|
|
339
|
+
baseRef: string
|
|
340
|
+
/** Local branches available as base refs, loaded when the create step opens. */
|
|
341
|
+
baseBranches: string[]
|
|
336
342
|
}
|
|
337
343
|
|
|
338
344
|
export interface ModalSessionPicker extends ModalBase {
|
|
@@ -417,6 +423,22 @@ export interface ModalWorktreeMove extends ModalBase {
|
|
|
417
423
|
deleteSource: boolean
|
|
418
424
|
}
|
|
419
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Standalone confirmation for a recoverable worktree delete failure triggered
|
|
428
|
+
* outside the new-tab picker (e.g. the sidebar's "Remove worktree"). Carries the
|
|
429
|
+
* params needed to re-run the delete with force once confirmed.
|
|
430
|
+
*/
|
|
431
|
+
export interface ModalWorktreeDeleteConfirm extends ModalBase {
|
|
432
|
+
type: 'worktree-delete-confirm'
|
|
433
|
+
sessionId: string
|
|
434
|
+
worktreeId: string
|
|
435
|
+
worktreeLabel: string
|
|
436
|
+
reason: string
|
|
437
|
+
closeTabs: boolean
|
|
438
|
+
/** Whether confirming force-deletes — true only after a recoverable failure. */
|
|
439
|
+
force: boolean
|
|
440
|
+
}
|
|
441
|
+
|
|
420
442
|
export type DirectoryResultType = 'git-repo' | 'worktree' | 'workspace'
|
|
421
443
|
|
|
422
444
|
export interface DirectoryResult {
|
|
@@ -440,6 +462,7 @@ export type ModalState =
|
|
|
440
462
|
| ModalUpdateAvailable
|
|
441
463
|
| ModalAIUsage
|
|
442
464
|
| ModalWorktreeMove
|
|
465
|
+
| ModalWorktreeDeleteConfirm
|
|
443
466
|
|
|
444
467
|
export interface LayoutState {
|
|
445
468
|
terminalCols: number
|
|
@@ -515,10 +538,10 @@ export type ModalAction =
|
|
|
515
538
|
| { type: 'open-new-tab-modal' }
|
|
516
539
|
| { type: 'set-new-tab-branch-error'; message: string | null }
|
|
517
540
|
| {
|
|
518
|
-
type: 'set-new-tab-worktree-delete-
|
|
519
|
-
|
|
520
|
-
message: string | null
|
|
541
|
+
type: 'set-new-tab-worktree-delete-prompt'
|
|
542
|
+
prompt: { worktreeId: string; reason: string } | null
|
|
521
543
|
}
|
|
544
|
+
| { type: 'set-new-tab-base-branches'; branches: string[] }
|
|
522
545
|
| { type: 'enter-new-tab-worktree-create' }
|
|
523
546
|
| { type: 'enter-new-tab-template-pick' }
|
|
524
547
|
| { type: 'enter-new-tab-template-shortcut' }
|
|
@@ -553,6 +576,15 @@ export type ModalAction =
|
|
|
553
576
|
| { type: 'open-ai-usage-modal' }
|
|
554
577
|
| { type: 'open-worktree-move-modal'; sourceWorktreeId: string }
|
|
555
578
|
| { type: 'toggle-worktree-move-delete' }
|
|
579
|
+
| {
|
|
580
|
+
type: 'open-worktree-delete-confirm'
|
|
581
|
+
sessionId: string
|
|
582
|
+
worktreeId: string
|
|
583
|
+
worktreeLabel: string
|
|
584
|
+
reason: string
|
|
585
|
+
closeTabs: boolean
|
|
586
|
+
force: boolean
|
|
587
|
+
}
|
|
556
588
|
|
|
557
589
|
// -- Session actions --
|
|
558
590
|
export type SessionAction =
|
|
@@ -570,7 +602,6 @@ export type SessionAction =
|
|
|
570
602
|
| { type: 'reorder-active-session'; delta: number }
|
|
571
603
|
| { type: 'set-session-status'; sessionId: string; status: SessionStatus }
|
|
572
604
|
| { type: 'add-worktree-record'; sessionId: string; worktree: WorktreeRecord; activate?: boolean }
|
|
573
|
-
| { type: 'remove-worktree-record'; sessionId: string; worktreeId: string }
|
|
574
605
|
| { type: 'set-active-worktree'; sessionId: string; worktreeId: string }
|
|
575
606
|
| {
|
|
576
607
|
type: 'update-worktree-record'
|
|
@@ -595,6 +626,7 @@ export type TabAction =
|
|
|
595
626
|
| { type: 'set-active-tab'; tabId: string }
|
|
596
627
|
| { type: 'move-active-tab'; delta: number }
|
|
597
628
|
| { type: 'reorder-active-tab'; delta: number }
|
|
629
|
+
| { type: 'reorder-tabs'; orderedTabIds: string[] }
|
|
598
630
|
| { type: 'reset-tab-session'; tabId: string }
|
|
599
631
|
| { type: 'rename-tab'; tabId: string; title: string }
|
|
600
632
|
| { type: 'append-tab-buffer'; tabId: string; chunk: string }
|
|
@@ -68,14 +68,24 @@ export const WorktreeRow = memo(function WorktreeRow({
|
|
|
68
68
|
[
|
|
69
69
|
'Remove worktree',
|
|
70
70
|
() =>
|
|
71
|
+
// Always confirm first. Confirming routes through the full delete side
|
|
72
|
+
// effect (closes the worktree's tabs, disposes their PTYs, prunes the
|
|
73
|
+
// snapshot, removes the git worktree). closeTabs (not force) cleans up
|
|
74
|
+
// the tabs while keeping the non-force `git worktree remove`, so
|
|
75
|
+
// uncommitted work in a temp worktree is still protected — a dirty
|
|
76
|
+
// worktree re-prompts for an explicit force-delete.
|
|
71
77
|
dispatchGlobal({
|
|
78
|
+
closeTabs: true,
|
|
79
|
+
force: false,
|
|
80
|
+
reason: 'Its assistant tabs will be closed and the worktree removed.',
|
|
72
81
|
sessionId: session.id,
|
|
73
|
-
type: '
|
|
82
|
+
type: 'open-worktree-delete-confirm',
|
|
74
83
|
worktreeId: worktree.id,
|
|
84
|
+
worktreeLabel: worktree.branch ?? worktree.name,
|
|
75
85
|
}),
|
|
76
86
|
],
|
|
77
87
|
]
|
|
78
|
-
}, [session.id, worktree.id, worktree.source])
|
|
88
|
+
}, [session.id, worktree.branch, worktree.id, worktree.name, worktree.source])
|
|
79
89
|
|
|
80
90
|
let bgColor: string | undefined
|
|
81
91
|
if (isActiveItem) {
|