@brimveyn/aimux 1.12.2 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/README.md +3 -0
  2. package/package.json +4 -3
  3. package/src/app-runtime/auto-commit-driver.ts +8 -4
  4. package/src/app-runtime/auto-commit-ref.ts +1 -1
  5. package/src/app-runtime/backend-attach-runtime.ts +18 -2
  6. package/src/app-runtime/backend-runtime-events.ts +1 -1
  7. package/src/app-runtime/pty-write.ts +52 -0
  8. package/src/app-runtime/selection-scroll.ts +2 -2
  9. package/src/app-runtime/session-actions.ts +16 -4
  10. package/src/app-runtime/side-effects.ts +598 -102
  11. package/src/app-runtime/snippet-actions.ts +25 -11
  12. package/src/app-runtime/use-auto-commit-driver.ts +7 -4
  13. package/src/app-runtime/use-backend-runtime.ts +3 -3
  14. package/src/app-runtime/use-directory-search.ts +7 -5
  15. package/src/app-runtime/use-mouse-handlers.ts +11 -6
  16. package/src/app-runtime/use-pane-size-report.ts +1 -1
  17. package/src/app-runtime/use-renderer-bindings.ts +143 -8
  18. package/src/app.tsx +25 -9
  19. package/src/auto-commit/headless-commands.ts +4 -6
  20. package/src/daemon/daemon.ts +3 -3
  21. package/src/daemon/runtime-paths.ts +1 -1
  22. package/src/daemon/session-manager.ts +1 -1
  23. package/src/daemon/session-registry.ts +10 -3
  24. package/src/diff-parser/parse-patch-files.ts +18 -18
  25. package/src/git/command-queue.ts +1 -1
  26. package/src/git/divergence.ts +46 -0
  27. package/src/git/git-diff.ts +12 -5
  28. package/src/git/git-poller.ts +33 -11
  29. package/src/git/git-status.ts +14 -3
  30. package/src/git/move-worktree.ts +96 -0
  31. package/src/git/use-repo-discovery.ts +2 -1
  32. package/src/git/worktree-divergence-poller.ts +59 -0
  33. package/src/git/worktree.ts +99 -0
  34. package/src/index.tsx +1 -1
  35. package/src/input/keymap/describe-bindings.ts +27 -9
  36. package/src/input/keymap/key-chord.ts +4 -4
  37. package/src/input/keymap/key-format.ts +2 -2
  38. package/src/input/keymap/sequence-resolver.ts +1 -1
  39. package/src/input/keymap/trie.ts +1 -1
  40. package/src/input/modes/bridge.ts +7 -0
  41. package/src/input/modes/transitions.ts +2 -1
  42. package/src/input/modes/types.ts +13 -1
  43. package/src/input/raw-input-handler.ts +34 -1
  44. package/src/input/terminal-text-extraction.ts +9 -5
  45. package/src/integrations/claude-syntax-overlay.ts +8 -8
  46. package/src/integrations/claude-theme-sync.ts +12 -12
  47. package/src/ipc/protocol.ts +3 -3
  48. package/src/platform/clipboard.ts +18 -2
  49. package/src/platform/worktree-deps.ts +22 -0
  50. package/src/platform/worktree-paths.ts +67 -0
  51. package/src/profile-paths.ts +3 -3
  52. package/src/pty/assistant-status-detection-loop.ts +2 -2
  53. package/src/pty/assistant-status-detector.ts +15 -6
  54. package/src/pty/command-registry.ts +8 -1
  55. package/src/pty/pty-manager.ts +1 -1
  56. package/src/services/ai-usage/adapters/claude.ts +5 -5
  57. package/src/services/ai-usage/adapters/codex.ts +8 -8
  58. package/src/services/ai-usage/cache.ts +2 -2
  59. package/src/services/ai-usage/pace.ts +3 -6
  60. package/src/services/ai-usage/provider.ts +1 -1
  61. package/src/session-backend/bootstrap.ts +2 -2
  62. package/src/session-backend/local-session-backend.ts +11 -11
  63. package/src/session-backend/remote-session-backend.ts +3 -3
  64. package/src/session-backend/types.ts +2 -2
  65. package/src/snippets/expand-variables.ts +112 -0
  66. package/src/snippets/run-shell-var.ts +93 -0
  67. package/src/snippets/trigger-detector.ts +105 -0
  68. package/src/state/ai-usage-store.ts +1 -1
  69. package/src/state/git-tree.ts +8 -4
  70. package/src/state/layout-tree.ts +8 -5
  71. package/src/state/reducers/git-mode-state.ts +44 -14
  72. package/src/state/reducers/git-panel-state.ts +38 -2
  73. package/src/state/reducers/modal-state.ts +385 -24
  74. package/src/state/reducers/session-state.ts +60 -1
  75. package/src/state/reducers/tab-state.ts +151 -85
  76. package/src/state/selectors.ts +6 -4
  77. package/src/state/session-catalog.ts +20 -5
  78. package/src/state/session-persistence.ts +8 -4
  79. package/src/state/session-worktrees.ts +227 -0
  80. package/src/state/snippet-catalog.ts +65 -4
  81. package/src/state/store.ts +1 -0
  82. package/src/state/toast-store.ts +95 -0
  83. package/src/state/types.ts +108 -16
  84. package/src/state/validation.ts +47 -3
  85. package/src/terminal-manager/manager-client.ts +15 -13
  86. package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
  87. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
  88. package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
  89. package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
  90. package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
  91. package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
  92. package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
  93. package/src/ui/components/git/git-panel.tsx +17 -10
  94. package/src/ui/components/git/git-view.tsx +79 -19
  95. package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
  96. package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
  97. package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
  98. package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
  99. package/src/ui/components/layout/session-bar.tsx +3 -3
  100. package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
  101. package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
  102. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
  103. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
  104. package/src/ui/components/layout/split-layout.tsx +1 -1
  105. package/src/ui/components/layout/terminal-pane.tsx +43 -40
  106. package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
  107. package/src/ui/components/modals/app/help-modal.tsx +4 -2
  108. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  109. package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
  110. package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
  111. package/src/ui/components/modals/shared/form.tsx +4 -5
  112. package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
  113. package/src/ui/components/modals/shared/picker.tsx +28 -5
  114. package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
  115. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
  116. package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
  117. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
  118. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
  119. package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
  120. package/src/ui/components/overlays/toast/toast-position.ts +45 -0
  121. package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
  122. package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
  123. package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
  124. package/src/ui/components/primitives/input-field.tsx +1 -1
  125. package/src/ui/components/primitives/list-item.tsx +3 -3
  126. package/src/ui/filter-themes.ts +1 -1
  127. package/src/ui/root.tsx +65 -14
  128. package/src/ui/session-ordering.ts +2 -2
  129. package/src/ui/status-bar-model.ts +14 -7
  130. package/src/ui/terminal-graphics/capabilities.ts +1 -1
  131. package/src/ui/terminal-graphics/format-fallback.ts +7 -9
  132. package/src/ui/terminal-graphics/kitty.ts +2 -7
  133. package/src/update/version-check.ts +1 -1
@@ -5,6 +5,7 @@ import {
5
5
  getAdjacentLeaf,
6
6
  getGroupIdForTab,
7
7
  getTreeForTab,
8
+ type LayoutNode,
8
9
  pruneLayoutTree,
9
10
  removeNode,
10
11
  resizeSplit,
@@ -12,6 +13,7 @@ import {
12
13
  splitNode,
13
14
  } from '../layout-tree'
14
15
  import { normalizeGroupedTabOrder } from '../session-persistence'
16
+ import { orderTabsByWorktree, withActiveWorktree } from '../session-worktrees'
15
17
  import { createDefaultTerminalModes } from '../terminal-modes'
16
18
  import {
17
19
  type AppAction,
@@ -118,12 +120,13 @@ function moveLayoutGroup(
118
120
 
119
121
  if (delta > 0 && groupEnd < tabs.length - 1) {
120
122
  const adjacentId = getTabIdAtIndex(tabs, groupEnd + 1)
121
- if (!adjacentId) {
123
+ if (!(adjacentId != null && adjacentId !== '')) {
122
124
  return null
123
125
  }
124
126
 
125
127
  const adjacentGroupId = getGroupIdForTab(state.tabGroupMap, adjacentId)
126
- const adjacentTree = adjacentGroupId ? state.layoutTrees[adjacentGroupId] : null
128
+ const adjacentTree =
129
+ adjacentGroupId != null && adjacentGroupId !== '' ? state.layoutTrees[adjacentGroupId] : null
127
130
  if (adjacentTree && adjacentTree.type === 'split') {
128
131
  const adjacentIds = new Set(allLeafIds(adjacentTree))
129
132
  let otherEnd = groupEnd + 1
@@ -149,12 +152,13 @@ function moveLayoutGroup(
149
152
 
150
153
  if (delta < 0 && groupStart > 0) {
151
154
  const adjacentId = getTabIdAtIndex(tabs, groupStart - 1)
152
- if (!adjacentId) {
155
+ if (!(adjacentId != null && adjacentId !== '')) {
153
156
  return null
154
157
  }
155
158
 
156
159
  const adjacentGroupId = getGroupIdForTab(state.tabGroupMap, adjacentId)
157
- const adjacentTree = adjacentGroupId ? state.layoutTrees[adjacentGroupId] : null
160
+ const adjacentTree =
161
+ adjacentGroupId != null && adjacentGroupId !== '' ? state.layoutTrees[adjacentGroupId] : null
158
162
  if (adjacentTree && adjacentTree.type === 'split') {
159
163
  const adjacentIds = new Set(allLeafIds(adjacentTree))
160
164
  let otherStart = groupStart - 1
@@ -187,13 +191,37 @@ function updateTab(
187
191
  }
188
192
 
189
193
  function getActiveIndex(state: AppState): number {
190
- if (!state.activeTabId) {
194
+ if (!(state.activeTabId != null && state.activeTabId !== '')) {
191
195
  return -1
192
196
  }
193
197
 
194
198
  return state.tabs.findIndex((tab) => tab.id === state.activeTabId)
195
199
  }
196
200
 
201
+ function getCurrentSession(state: AppState) {
202
+ return state.currentSessionId != null && state.currentSessionId !== ''
203
+ ? state.sessions.find((session) => session.id === state.currentSessionId)
204
+ : undefined
205
+ }
206
+
207
+ function withActiveTabWorktree(state: AppState, tabId: string | null): AppState {
208
+ if (
209
+ tabId == null ||
210
+ tabId === '' ||
211
+ !(state.currentSessionId != null && state.currentSessionId !== '')
212
+ )
213
+ return state
214
+ const tab = state.tabs.find((entry) => entry.id === tabId)
215
+ if (!(tab?.worktreeId != null && tab?.worktreeId !== '')) return state
216
+ const worktreeId = tab.worktreeId
217
+ return {
218
+ ...state,
219
+ sessions: state.sessions.map((session) =>
220
+ session.id === state.currentSessionId ? withActiveWorktree(session, worktreeId) : session
221
+ ),
222
+ }
223
+ }
224
+
197
225
  function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
198
226
  if (indexToClose < 0 || indexToClose >= state.tabs.length) {
199
227
  return state
@@ -203,8 +231,11 @@ function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
203
231
  const tabs = state.tabs.filter((_, index) => index !== indexToClose)
204
232
 
205
233
  // Find the tree for the closing tab's group
206
- const groupId = closingTabId ? getGroupIdForTab(state.tabGroupMap, closingTabId) : null
207
- const groupTree = groupId ? (state.layoutTrees[groupId] ?? null) : null
234
+ const groupId =
235
+ closingTabId != null && closingTabId !== ''
236
+ ? getGroupIdForTab(state.tabGroupMap, closingTabId)
237
+ : null
238
+ const groupTree = groupId != null && groupId !== '' ? (state.layoutTrees[groupId] ?? null) : null
208
239
 
209
240
  let nextActiveTabId: string | null
210
241
  if (state.activeTabId === closingTabId) {
@@ -223,7 +254,13 @@ function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
223
254
  // Update the group's tree and clean up if needed
224
255
  let newLayoutTrees = state.layoutTrees
225
256
  let newTabGroupMap = state.tabGroupMap
226
- if (closingTabId && groupId && groupTree) {
257
+ if (
258
+ closingTabId != null &&
259
+ closingTabId !== '' &&
260
+ groupId != null &&
261
+ groupId !== '' &&
262
+ groupTree
263
+ ) {
227
264
  const newTree = removeNode(groupTree, closingTabId)
228
265
  newLayoutTrees = { ...state.layoutTrees }
229
266
  newTabGroupMap = { ...state.tabGroupMap }
@@ -239,38 +276,46 @@ function closeTabAtIndex(state: AppState, indexToClose: number): AppState {
239
276
  }
240
277
  }
241
278
 
242
- return {
243
- ...state,
244
- activeTabId: nextActiveTabId,
245
- focusMode: tabs.length === 0 ? 'navigation' : state.focusMode,
246
- layoutTrees: newLayoutTrees,
247
- tabGroupMap: newTabGroupMap,
248
- tabs,
249
- }
279
+ return withActiveTabWorktree(
280
+ {
281
+ ...state,
282
+ activeTabId: nextActiveTabId,
283
+ focusMode: tabs.length === 0 ? 'navigation' : state.focusMode,
284
+ layoutTrees: newLayoutTrees,
285
+ tabGroupMap: newTabGroupMap,
286
+ tabs,
287
+ },
288
+ nextActiveTabId
289
+ )
250
290
  }
251
291
 
252
292
  export function reduceTabState(state: AppState, action: AppAction): AppState | null {
253
293
  switch (action.type) {
254
294
  case 'add-tab': {
255
295
  const newTab = { ...action.tab, activity: action.tab.activity ?? 'idle' }
256
- return {
257
- ...state,
258
- activeTabId: newTab.id,
259
- focusMode: 'navigation',
260
- modal: { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: null },
261
- tabs: [...state.tabs, newTab],
262
- }
296
+ return withActiveTabWorktree(
297
+ {
298
+ ...state,
299
+ activeTabId: newTab.id,
300
+ focusMode: 'navigation',
301
+ modal: { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: null },
302
+ tabs: [...state.tabs, newTab],
303
+ },
304
+ newTab.id
305
+ )
263
306
  }
264
307
  case 'hydrate-workspace': {
265
308
  const hydratedActiveTabId =
266
- action.activeTabId && action.tabs.some((tab) => tab.id === action.activeTabId)
309
+ action.activeTabId != null &&
310
+ action.activeTabId !== '' &&
311
+ action.tabs.some((tab) => tab.id === action.activeTabId)
267
312
  ? action.activeTabId
268
313
  : (action.tabs[0]?.id ?? null)
269
314
  const tabIds = new Set(action.tabs.map((t) => t.id))
270
315
 
271
316
  // Restore from new multi-tree format or migrate from legacy single tree
272
- let hydratedTrees: Record<string, import('../layout-tree').LayoutNode> = {}
273
- let hydratedGroupMap: Record<string, string> = {}
317
+ const hydratedTrees: Record<string, LayoutNode> = {}
318
+ const hydratedGroupMap: Record<string, string> = {}
274
319
 
275
320
  if (action.layoutTrees && action.tabGroupMap) {
276
321
  // New format: prune each group tree
@@ -296,14 +341,17 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
296
341
  }
297
342
  }
298
343
 
299
- return {
300
- ...state,
301
- activeTabId: hydratedActiveTabId,
302
- focusMode: 'navigation',
303
- layoutTrees: hydratedTrees,
304
- tabGroupMap: hydratedGroupMap,
305
- tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
306
- }
344
+ return withActiveTabWorktree(
345
+ {
346
+ ...state,
347
+ activeTabId: hydratedActiveTabId,
348
+ focusMode: 'navigation',
349
+ layoutTrees: hydratedTrees,
350
+ tabGroupMap: hydratedGroupMap,
351
+ tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
352
+ },
353
+ hydratedActiveTabId
354
+ )
307
355
  }
308
356
  case 'close-tab':
309
357
  return closeTabAtIndex(
@@ -313,18 +361,19 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
313
361
  case 'close-active-tab':
314
362
  return closeTabAtIndex(state, getActiveIndex(state))
315
363
  case 'set-active-tab':
316
- return { ...state, activeTabId: action.tabId }
364
+ return withActiveTabWorktree({ ...state, activeTabId: action.tabId }, action.tabId)
317
365
  case 'move-active-tab': {
318
366
  if (state.tabs.length === 0) {
319
367
  return state
320
368
  }
321
- const currentIndex = state.tabs.findIndex((tab) => tab.id === state.activeTabId)
369
+ const orderedTabs = orderTabsByWorktree(state.tabs, getCurrentSession(state))
370
+ const currentIndex = orderedTabs.findIndex((tab) => tab.id === state.activeTabId)
322
371
  const safeIndex = currentIndex === -1 ? 0 : currentIndex
323
- const nextIndex = (safeIndex + action.delta + state.tabs.length) % state.tabs.length
324
- const nextTabId = state.tabs[nextIndex]?.id
325
- return !nextTabId || nextTabId === state.activeTabId
372
+ const nextIndex = (safeIndex + action.delta + orderedTabs.length) % orderedTabs.length
373
+ const nextTabId = orderedTabs[nextIndex]?.id
374
+ return nextTabId == null || nextTabId === '' || nextTabId === state.activeTabId
326
375
  ? state
327
- : { ...state, activeTabId: nextTabId }
376
+ : withActiveTabWorktree({ ...state, activeTabId: nextTabId }, nextTabId)
328
377
  }
329
378
  case 'reorder-active-tab': {
330
379
  const activeIndex = getActiveIndex(state)
@@ -333,12 +382,19 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
333
382
  }
334
383
 
335
384
  // Group-aware reordering: move entire layout group together
336
- const activeGroupId = state.activeTabId
337
- ? getGroupIdForTab(state.tabGroupMap, state.activeTabId)
338
- : null
339
- const activeGroupTree = activeGroupId ? state.layoutTrees[activeGroupId] : null
385
+ const activeGroupId =
386
+ state.activeTabId != null && state.activeTabId !== ''
387
+ ? getGroupIdForTab(state.tabGroupMap, state.activeTabId)
388
+ : null
389
+ const activeGroupTree =
390
+ activeGroupId != null && activeGroupId !== '' ? state.layoutTrees[activeGroupId] : null
340
391
  const layoutIds = activeGroupTree ? allLeafIds(activeGroupTree) : []
341
- if (layoutIds.length > 1 && state.activeTabId && layoutIds.includes(state.activeTabId)) {
392
+ if (
393
+ layoutIds.length > 1 &&
394
+ state.activeTabId != null &&
395
+ state.activeTabId !== '' &&
396
+ layoutIds.includes(state.activeTabId)
397
+ ) {
342
398
  const tabs = moveLayoutGroup(state, activeIndex, layoutIds, action.delta)
343
399
  if (!tabs) {
344
400
  return state
@@ -354,11 +410,12 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
354
410
  }
355
411
  // Check if target belongs to a group → skip over the entire group
356
412
  const targetTabId = getTabIdAtIndex(state.tabs, nextIndex)
357
- if (!targetTabId) {
413
+ if (!(targetTabId != null && targetTabId !== '')) {
358
414
  return state
359
415
  }
360
416
  const targetGroupId = getGroupIdForTab(state.tabGroupMap, targetTabId)
361
- const targetTree = targetGroupId ? state.layoutTrees[targetGroupId] : null
417
+ const targetTree =
418
+ targetGroupId != null && targetGroupId !== '' ? state.layoutTrees[targetGroupId] : null
362
419
  if (targetTree && targetTree.type === 'split') {
363
420
  const targetIds = new Set(allLeafIds(targetTree))
364
421
  const tabs = moveTabAcrossTargetGroup(
@@ -380,22 +437,25 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
380
437
  return { ...state, tabs }
381
438
  }
382
439
  case 'reset-tab-session':
383
- return {
384
- ...state,
385
- activeTabId: action.tabId,
386
- focusMode: 'navigation',
387
- tabs: updateTab(state.tabs, action.tabId, (tab) => ({
388
- ...tab,
389
- activity: 'idle',
390
- buffer: '',
391
- errorMessage: undefined,
392
- exitCode: undefined,
393
- scrollIntent: DEFAULT_SCROLL_INTENT,
394
- status: 'starting',
395
- terminalModes: createDefaultTerminalModes(),
396
- viewport: undefined,
397
- })),
398
- }
440
+ return withActiveTabWorktree(
441
+ {
442
+ ...state,
443
+ activeTabId: action.tabId,
444
+ focusMode: 'navigation',
445
+ tabs: updateTab(state.tabs, action.tabId, (tab) => ({
446
+ ...tab,
447
+ activity: 'idle',
448
+ buffer: '',
449
+ errorMessage: undefined,
450
+ exitCode: undefined,
451
+ scrollIntent: DEFAULT_SCROLL_INTENT,
452
+ status: 'starting',
453
+ terminalModes: createDefaultTerminalModes(),
454
+ viewport: undefined,
455
+ })),
456
+ },
457
+ action.tabId
458
+ )
399
459
  case 'append-tab-buffer':
400
460
  return {
401
461
  ...state,
@@ -449,16 +509,17 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
449
509
  tabs: updateTab(state.tabs, action.tabId, (tab) => ({ ...tab, title: action.title })),
450
510
  }
451
511
  case 'split-pane': {
452
- if (!state.activeTabId) {
512
+ if (!(state.activeTabId != null && state.activeTabId !== '')) {
453
513
  return state
454
514
  }
455
515
  const newTab = { ...action.newTab, activity: action.newTab.activity ?? 'idle' }
456
516
 
457
517
  // Find existing group for active tab, or create a new one
458
518
  let groupId = getGroupIdForTab(state.tabGroupMap, state.activeTabId)
459
- let tree: import('../layout-tree').LayoutNode
460
- const existingTree = groupId ? state.layoutTrees[groupId] : undefined
461
- if (groupId && existingTree) {
519
+ let tree: LayoutNode
520
+ const existingTree =
521
+ groupId != null && groupId !== '' ? state.layoutTrees[groupId] : undefined
522
+ if (groupId != null && groupId !== '' && existingTree) {
462
523
  tree = existingTree
463
524
  } else {
464
525
  groupId = createGroupId()
@@ -472,31 +533,34 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
472
533
  let insertIndex = state.tabs.length
473
534
  for (let i = state.tabs.length - 1; i >= 0; i--) {
474
535
  const tabId = getTabIdAtIndex(state.tabs, i)
475
- if (tabId && layoutIdSet.has(tabId)) {
536
+ if (tabId != null && tabId !== '' && layoutIdSet.has(tabId)) {
476
537
  insertIndex = i + 1
477
538
  break
478
539
  }
479
540
  }
480
541
  const tabs = [...state.tabs.slice(0, insertIndex), newTab, ...state.tabs.slice(insertIndex)]
481
542
 
482
- return {
483
- ...state,
484
- activeTabId: newTab.id,
485
- layoutTrees: { ...state.layoutTrees, [groupId]: newTree },
486
- tabGroupMap: {
487
- ...state.tabGroupMap,
488
- [newTab.id]: groupId,
489
- [state.activeTabId]: groupId,
543
+ return withActiveTabWorktree(
544
+ {
545
+ ...state,
546
+ activeTabId: newTab.id,
547
+ layoutTrees: { ...state.layoutTrees, [groupId]: newTree },
548
+ tabGroupMap: {
549
+ ...state.tabGroupMap,
550
+ [newTab.id]: groupId,
551
+ [state.activeTabId]: groupId,
552
+ },
553
+ tabs,
490
554
  },
491
- tabs,
492
- }
555
+ newTab.id
556
+ )
493
557
  }
494
558
  case 'close-pane': {
495
559
  const idx = state.tabs.findIndex((tab) => tab.id === action.tabId)
496
560
  return closeTabAtIndex(state, idx)
497
561
  }
498
562
  case 'focus-pane-direction': {
499
- if (!state.activeTabId) {
563
+ if (!(state.activeTabId != null && state.activeTabId !== '')) {
500
564
  return state
501
565
  }
502
566
  const focusTree = getTreeForTab(state.layoutTrees, state.tabGroupMap, state.activeTabId)
@@ -504,15 +568,16 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
504
568
  return state
505
569
  }
506
570
  const neighbor = getAdjacentLeaf(focusTree, state.activeTabId, action.direction)
507
- if (!neighbor) {
571
+ if (!(neighbor != null && neighbor !== '')) {
508
572
  return state
509
573
  }
510
- return { ...state, activeTabId: neighbor }
574
+ return withActiveTabWorktree({ ...state, activeTabId: neighbor }, neighbor)
511
575
  }
512
576
  case 'resize-pane': {
513
577
  const resizeGroupId = getGroupIdForTab(state.tabGroupMap, action.tabId)
514
- const resizeTree = resizeGroupId ? state.layoutTrees[resizeGroupId] : null
515
- if (!resizeGroupId || !resizeTree) {
578
+ const resizeTree =
579
+ resizeGroupId != null && resizeGroupId !== '' ? state.layoutTrees[resizeGroupId] : null
580
+ if (resizeGroupId == null || resizeGroupId === '' || !resizeTree) {
516
581
  return state
517
582
  }
518
583
  const newTree = resizeSplit(resizeTree, action.tabId, action.delta, action.axis)
@@ -523,8 +588,9 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
523
588
  }
524
589
  case 'set-split-ratio': {
525
590
  const ratioGroupId = getGroupIdForTab(state.tabGroupMap, action.tabId)
526
- const ratioTree = ratioGroupId ? state.layoutTrees[ratioGroupId] : null
527
- if (!ratioGroupId || !ratioTree) {
591
+ const ratioTree =
592
+ ratioGroupId != null && ratioGroupId !== '' ? state.layoutTrees[ratioGroupId] : null
593
+ if (ratioGroupId == null || ratioGroupId === '' || !ratioTree) {
528
594
  return state
529
595
  }
530
596
  const newTree = setSplitRatio(ratioTree, action.tabId, action.ratio, action.axis)
@@ -5,7 +5,7 @@ export function filterAssistants(
5
5
  options: AssistantOption[],
6
6
  filter: string | null
7
7
  ): AssistantOption[] {
8
- if (!filter) return options
8
+ if (!(filter != null && filter !== '')) return options
9
9
  const lower = filter.toLowerCase()
10
10
  return options.filter(
11
11
  (o) => o.label.toLowerCase().includes(lower) || o.description.toLowerCase().includes(lower)
@@ -13,7 +13,7 @@ export function filterAssistants(
13
13
  }
14
14
 
15
15
  export function filterSessions(sessions: SessionRecord[], filter: string | null): SessionRecord[] {
16
- if (!filter) {
16
+ if (!(filter != null && filter !== '')) {
17
17
  return sessions
18
18
  }
19
19
 
@@ -21,12 +21,14 @@ export function filterSessions(sessions: SessionRecord[], filter: string | null)
21
21
  return sessions.filter(
22
22
  (session) =>
23
23
  session.name.toLowerCase().includes(lower) ||
24
- (session.projectPath && session.projectPath.toLowerCase().includes(lower))
24
+ (session.projectPath != null &&
25
+ session.projectPath !== '' &&
26
+ session.projectPath.toLowerCase().includes(lower))
25
27
  )
26
28
  }
27
29
 
28
30
  export function filterSnippets(snippets: SnippetRecord[], filter: string | null): SnippetRecord[] {
29
- if (!filter) {
31
+ if (!(filter != null && filter !== '')) {
30
32
  return snippets
31
33
  }
32
34
 
@@ -6,6 +6,8 @@ import type { SessionRecord } from './types'
6
6
  import { loadConfig, saveConfig } from '../config'
7
7
  import { logDebug } from '../debug/input-log'
8
8
  import { getProfileConfigDir } from '../profile-paths'
9
+ import { ensureSessionWorktrees } from './session-worktrees'
10
+ import { toast } from './toast-store'
9
11
  import { isSessionRecord } from './validation'
10
12
 
11
13
  interface SessionCatalogFile {
@@ -46,10 +48,14 @@ export function loadSessionCatalog(): SessionRecord[] {
46
48
  const { file, issue } = readCatalogFile()
47
49
  if (file) {
48
50
  logDebug('sessions.catalog.load', { sessionCount: file.sessions.length })
49
- return normalizeOrder(file.sessions)
51
+ const normalized = normalizeSessions(file.sessions)
52
+ if (JSON.stringify(normalized) !== JSON.stringify(file.sessions)) {
53
+ saveSessionCatalog(normalized)
54
+ }
55
+ return normalized
50
56
  }
51
57
 
52
- if (issue) {
58
+ if (issue != null && issue !== '') {
53
59
  logDebug('sessions.catalog.loadIssue', { issue, path: SESSIONS_PATH })
54
60
  }
55
61
 
@@ -69,13 +75,14 @@ export function loadSessionCatalog(): SessionRecord[] {
69
75
  workspaceSnapshot: config.workspaceSnapshot,
70
76
  }
71
77
 
72
- saveSessionCatalog([migrated])
78
+ const normalized = normalizeSessions([migrated])
79
+ saveSessionCatalog(normalized)
73
80
  saveConfig({ ...config, workspaceSnapshot: undefined })
74
81
  logDebug('sessions.catalog.migrateLegacyWorkspace', {
75
82
  migratedSessionId: migrated.id,
76
83
  tabCount: migrated.workspaceSnapshot?.tabs.length ?? 0,
77
84
  })
78
- return [migrated]
85
+ return normalized
79
86
  }
80
87
 
81
88
  export function saveSessionCatalog(sessions: SessionRecord[]): void {
@@ -84,11 +91,15 @@ export function saveSessionCatalog(sessions: SessionRecord[]): void {
84
91
  writeFileSync(SESSIONS_PATH, `${JSON.stringify({ sessions, version: 1 }, null, 2)}\n`)
85
92
  logDebug('sessions.catalog.save', { sessionCount: sessions.length })
86
93
  } catch (error) {
94
+ const message = error instanceof Error ? error.message : String(error)
87
95
  logDebug('sessions.catalog.saveError', {
88
- error: error instanceof Error ? error.message : String(error),
96
+ error: message,
89
97
  path: SESSIONS_PATH,
90
98
  sessionCount: sessions.length,
91
99
  })
100
+ // Persisting the catalog underpins workspace/worktree state — a silent
101
+ // failure means the user loses sessions on restart. Surface it.
102
+ toast.error(`Failed to save sessions: ${message}`)
92
103
  }
93
104
  }
94
105
 
@@ -126,3 +137,7 @@ function normalizeOrder(sessions: SessionRecord[]): SessionRecord[] {
126
137
  const merged = [...withOrder, ...withoutOrder]
127
138
  return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
128
139
  }
140
+
141
+ function normalizeSessions(sessions: SessionRecord[]): SessionRecord[] {
142
+ return normalizeOrder(sessions).map((session) => ensureSessionWorktrees(session))
143
+ }
@@ -52,6 +52,7 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
52
52
  terminalModes: tab.terminalModes,
53
53
  title: tab.title,
54
54
  viewport: tab.viewport,
55
+ worktreeId: tab.worktreeId,
55
56
  })),
56
57
  version: 1,
57
58
  }
@@ -80,6 +81,7 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
80
81
  terminalModes: tab.terminalModes,
81
82
  title: tab.title,
82
83
  viewport: tab.viewport,
84
+ worktreeId: tab.worktreeId,
83
85
  }))
84
86
  }
85
87
 
@@ -140,7 +142,7 @@ export function normalizeGroupedTabOrder(
140
142
 
141
143
  for (const tab of tabs) {
142
144
  const groupId = tabGroupMap[tab.id]
143
- if (!groupId) {
145
+ if (!(groupId != null && groupId !== '')) {
144
146
  continue
145
147
  }
146
148
 
@@ -160,9 +162,9 @@ export function normalizeGroupedTabOrder(
160
162
 
161
163
  for (const tab of tabs) {
162
164
  const groupId = tabGroupMap[tab.id]
163
- const groupTree = groupId ? layoutTrees[groupId] : undefined
165
+ const groupTree = groupId != null && groupId !== '' ? layoutTrees[groupId] : undefined
164
166
 
165
- if (!groupId || !groupTree || groupTree.type !== 'split') {
167
+ if (groupId == null || groupId === '' || !groupTree || groupTree.type !== 'split') {
166
168
  orderedTabs.push(tab)
167
169
  continue
168
170
  }
@@ -194,7 +196,9 @@ export function restoreWorkspaceState(
194
196
  > {
195
197
  const tabs = restoreTabsFromWorkspace(workspaceSnapshot)
196
198
  const activeTabId =
197
- workspaceSnapshot?.activeTabId && tabs.some((tab) => tab.id === workspaceSnapshot.activeTabId)
199
+ workspaceSnapshot?.activeTabId != null &&
200
+ workspaceSnapshot?.activeTabId !== '' &&
201
+ tabs.some((tab) => tab.id === workspaceSnapshot.activeTabId)
198
202
  ? workspaceSnapshot.activeTabId
199
203
  : (tabs[0]?.id ?? null)
200
204