@brimveyn/aimux 1.14.0 → 1.14.1

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 (32) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +145 -1
  3. package/src/app-runtime/split-drag-controller.ts +3 -1
  4. package/src/app-runtime/use-terminal-resize.ts +1 -4
  5. package/src/app.tsx +0 -3
  6. package/src/config.ts +1 -12
  7. package/src/git/worktree-branch-poller.ts +54 -0
  8. package/src/input/modes/types.ts +2 -0
  9. package/src/state/layout-tree.ts +114 -4
  10. package/src/state/reducers/session-state.ts +20 -0
  11. package/src/state/reducers/tab-state.ts +20 -2
  12. package/src/state/reducers/ui-state.ts +0 -3
  13. package/src/state/session-worktrees.ts +45 -2
  14. package/src/state/store.ts +0 -3
  15. package/src/state/tab-entries.ts +74 -0
  16. package/src/state/types.ts +1 -4
  17. package/src/state/workspace-save.ts +0 -1
  18. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  19. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  20. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  21. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  22. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  23. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  24. package/src/ui/components/layout/split-layout.tsx +20 -39
  25. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  26. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  27. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  28. package/src/ui/root.tsx +68 -63
  29. package/src/ui/components/layout/session-bar.tsx +0 -296
  30. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  31. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  32. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -60,7 +60,7 @@
60
60
  "bump": "bun run scripts/bump.ts"
61
61
  },
62
62
  "dependencies": {
63
- "@brimveyn/aimux-config": "0.6.3",
63
+ "@brimveyn/aimux-config": "0.6.4",
64
64
  "@opentui/core": "^0.1.90",
65
65
  "@opentui/react": "^0.1.90",
66
66
  "@resvg/resvg-wasm": "^2.6.2",
@@ -43,6 +43,7 @@ import {
43
43
  isCommandAvailable,
44
44
  parseCommand,
45
45
  } from '../pty/command-registry'
46
+ import { appStore } from '../state/app-store'
46
47
  import { createTerminalBounds } from '../state/layout-resize'
47
48
  import {
48
49
  allLeafIds,
@@ -56,8 +57,15 @@ import {
56
57
  } from '../state/layout-tree'
57
58
  import { filterAssistants, filterSessions, filterSnippets } from '../state/selectors'
58
59
  import { saveSessionCatalog } from '../state/session-catalog'
59
- import { getActiveWorktree, getSessionProjectPath } from '../state/session-worktrees'
60
+ import {
61
+ filterTabsForActiveWorktree,
62
+ getActiveWorktree,
63
+ getSessionProjectPath,
64
+ withActiveWorktree,
65
+ } from '../state/session-worktrees'
60
66
  import { getSnippetsCatalogPath, isConfigSnippetId } from '../state/snippet-catalog'
67
+ import { appReducer } from '../state/store'
68
+ import { buildTabEntries } from '../state/tab-entries'
61
69
  import { createDefaultTerminalModes } from '../state/terminal-modes'
62
70
  import { toast } from '../state/toast-store'
63
71
  import { saveCurrentWorkspace } from '../state/workspace-save'
@@ -71,6 +79,7 @@ import {
71
79
  handleRenameSessionEffect,
72
80
  handleSwitchSessionEffect,
73
81
  restartTabSession,
82
+ switchSessionRecords,
74
83
  } from './session-actions'
75
84
  import {
76
85
  handleDeleteSnippetEffect,
@@ -789,6 +798,14 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
789
798
  handleSwitchSessionByIndex(ctx, effect.index)
790
799
  return
791
800
  }
801
+ case 'cycle-sidebar-item': {
802
+ handleCycleSidebarItem(ctx, effect.direction)
803
+ return
804
+ }
805
+ case 'switch-tab-by-index': {
806
+ handleSwitchTabByIndex(ctx, effect.index)
807
+ return
808
+ }
792
809
  case 'toggle-transparent': {
793
810
  const next = !getTransparent()
794
811
  setTransparent(next)
@@ -1060,6 +1077,133 @@ function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void
1060
1077
  handleSwitchSessionEffect(state, backend, dispatch, target)
1061
1078
  }
1062
1079
 
1080
+ interface SidebarItem {
1081
+ sessionId: string
1082
+ worktreeId: string | null
1083
+ }
1084
+
1085
+ function buildSidebarItems(state: AppState): SidebarItem[] {
1086
+ const ordered = [...state.sessions].sort(
1087
+ (a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER)
1088
+ )
1089
+ const items: SidebarItem[] = []
1090
+ for (const session of ordered) {
1091
+ items.push({ sessionId: session.id, worktreeId: null })
1092
+ const worktrees = session.worktrees ?? []
1093
+ const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1094
+ for (const wt of worktrees) {
1095
+ if (wt.id === primary?.id) continue
1096
+ items.push({ sessionId: session.id, worktreeId: wt.id })
1097
+ }
1098
+ }
1099
+ return items
1100
+ }
1101
+
1102
+ function findCurrentSidebarItem(state: AppState, items: SidebarItem[]): number {
1103
+ const sessionId = state.currentSessionId
1104
+ if (sessionId == null || sessionId === '') return -1
1105
+ const session = state.sessions.find((s) => s.id === sessionId)
1106
+ const worktrees = session?.worktrees ?? []
1107
+ const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1108
+ const activeWtId = session?.activeWorktreeId ?? null
1109
+ // The workspace row IS the primary worktree (no separate row), so an active
1110
+ // primary or undefined active maps to the workspace-item.
1111
+ const targetWorktreeId = activeWtId == null || activeWtId === primary?.id ? null : activeWtId
1112
+ return items.findIndex(
1113
+ (item) => item.sessionId === sessionId && item.worktreeId === targetWorktreeId
1114
+ )
1115
+ }
1116
+
1117
+ function handleCycleSidebarItem(ctx: SideEffectContext, direction: 1 | -1): void {
1118
+ const { backend, dispatch } = ctx
1119
+ // Read fresh from the store, not ctx.state (which is a per-render
1120
+ // snapshot). Rapid key presses fire before React re-renders, so ctx.state
1121
+ // can lag the actual store.
1122
+ const state = appStore.getState()
1123
+ const items = buildSidebarItems(state)
1124
+ if (items.length === 0) return
1125
+ const currentIdx = findCurrentSidebarItem(state, items)
1126
+ // If we don't know the current, jump to first/last depending on direction.
1127
+ let startIdx: number
1128
+ if (currentIdx >= 0) {
1129
+ startIdx = currentIdx
1130
+ } else {
1131
+ startIdx = direction === 1 ? -1 : 0
1132
+ }
1133
+ const len = items.length
1134
+ const target = items[(((startIdx + direction) % len) + len) % len]
1135
+ if (!target) return
1136
+
1137
+ const session = state.sessions.find((s) => s.id === target.sessionId)
1138
+ if (!session) return
1139
+
1140
+ // Determine the worktree to activate. For workspace-items, that's the
1141
+ // primary; for worktree-items, the specific worktree.
1142
+ const worktrees = session.worktrees ?? []
1143
+ const primary = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
1144
+ const targetWorktreeId = target.worktreeId ?? primary?.id
1145
+
1146
+ const isCrossWorkspace = session.id !== state.currentSessionId
1147
+ const needsWorktreeChange =
1148
+ targetWorktreeId != null && targetWorktreeId !== session.activeWorktreeId
1149
+
1150
+ if (isCrossWorkspace) {
1151
+ // Bundle the worktree change into the session record AND fold the
1152
+ // session switch's two dispatches (set-sessions + load-session) into a
1153
+ // SINGLE Zustand setState call — otherwise each dispatch fires a
1154
+ // separate subscription notification and the @opentui/react reconciler
1155
+ // paints an intermediate frame where the new session is current but
1156
+ // the old activeWorktreeId still holds, producing the visible flicker.
1157
+ const patchedSession = needsWorktreeChange
1158
+ ? withActiveWorktree(session, targetWorktreeId)
1159
+ : session
1160
+ const patchedState: AppState = needsWorktreeChange
1161
+ ? {
1162
+ ...state,
1163
+ sessions: state.sessions.map((s) => (s.id === patchedSession.id ? patchedSession : s)),
1164
+ }
1165
+ : state
1166
+ const sessions = switchSessionRecords(patchedState, patchedSession)
1167
+ saveSessionCatalog(sessions)
1168
+ void backend.destroy(true)
1169
+ appStore.setState((current) => {
1170
+ const afterSet = appReducer(current, { sessions, type: 'set-sessions' })
1171
+ return appReducer(afterSet, {
1172
+ sessionId: patchedSession.id,
1173
+ type: 'load-session',
1174
+ workspaceSnapshot: patchedSession.workspaceSnapshot,
1175
+ })
1176
+ })
1177
+ return
1178
+ }
1179
+
1180
+ if (needsWorktreeChange) {
1181
+ dispatch({
1182
+ sessionId: session.id,
1183
+ type: 'set-active-worktree',
1184
+ worktreeId: targetWorktreeId,
1185
+ })
1186
+ }
1187
+ }
1188
+
1189
+ function handleSwitchTabByIndex(ctx: SideEffectContext, index: number): void {
1190
+ const { dispatch, state } = ctx
1191
+ const currentSession =
1192
+ state.currentSessionId != null && state.currentSessionId !== ''
1193
+ ? state.sessions.find((s) => s.id === state.currentSessionId)
1194
+ : undefined
1195
+ const visible = filterTabsForActiveWorktree(state.tabs, currentSession)
1196
+ const entries = buildTabEntries(visible, state.layoutTrees, state.tabGroupMap, state.activeTabId)
1197
+ const target = entries[index - 1]
1198
+ if (!target) {
1199
+ logInputDebug('app.tabBar.switchOutOfRange', { index, total: entries.length })
1200
+ return
1201
+ }
1202
+ const targetTabId = target.kind === 'single' ? target.tab.id : target.activeLeafId
1203
+ if (targetTabId === state.activeTabId) return
1204
+ dispatch({ tabId: targetTabId, type: 'set-active-tab' })
1205
+ }
1206
+
1063
1207
  function replaceSession(
1064
1208
  state: AppState,
1065
1209
  sessionId: string,
@@ -29,7 +29,9 @@ function getScreenPosition(event: OtuiMouseEvent, axis: ScreenAxis): number {
29
29
 
30
30
  export function getSplitRatioFromDrag(event: OtuiMouseEvent, drag: SplitDragState): number {
31
31
  const position = getScreenPosition(event, drag.direction === 'vertical' ? 'x' : 'y')
32
- return (position - drag.screenStart) / drag.totalSize
32
+ const total = Math.max(1, drag.totalSize)
33
+ const cells = Math.round(position - drag.screenStart)
34
+ return cells / total
33
35
  }
34
36
 
35
37
  export function getAxisDeltaFromDrag(event: OtuiMouseEvent, drag: AxisDragState): number {
@@ -175,8 +175,7 @@ export function useTerminalResize({
175
175
  const terminalSize = useMemo(() => {
176
176
  const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
177
177
  const sessionBarRows = state.sessionBar.visible ? 1 : 0
178
- const sessionBarTopOffset =
179
- state.sessionBar.visible && state.sessionBar.position === 'top' ? 1 : 0
178
+ const sessionBarTopOffset = sessionBarRows
180
179
  const reservedRows =
181
180
  MAIN_AREA_VERTICAL_PADDING +
182
181
  STATUS_BAR_HEIGHT +
@@ -205,7 +204,6 @@ export function useTerminalResize({
205
204
  state.sidebar.visible,
206
205
  state.sidebar.width,
207
206
  state.sessionBar.visible,
208
- state.sessionBar.position,
209
207
  gitPaneInPaneMode,
210
208
  state.gitPane.position,
211
209
  state.gitPane.paneRatio,
@@ -233,7 +231,6 @@ export function useTerminalResize({
233
231
  state.sidebar.visible,
234
232
  state.sidebar.width,
235
233
  state.sessionBar.visible,
236
- state.sessionBar.position,
237
234
  gitPaneInPaneMode,
238
235
  state.gitPane.position,
239
236
  state.gitPane.paneRatio,
package/src/app.tsx CHANGED
@@ -100,8 +100,6 @@ export function App({
100
100
  const json = loadConfig()
101
101
  const sessionBarVisible =
102
102
  resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
103
- const sessionBarPosition =
104
- resolvedConfig.sessionBar?.initialPosition ?? json.sessionBarPosition ?? 'top'
105
103
  const sidebarOverrides = json.sidebar
106
104
 
107
105
  // Merge config-file gitPane (persisted prefs) with user's resolved gitPane
@@ -149,7 +147,6 @@ export function App({
149
147
  sessionCatalog.length === 0,
150
148
  {
151
149
  gitPane: gitPaneOverrides,
152
- sessionBarPosition,
153
150
  sessionBarVisible,
154
151
  sidebar: sidebarOverrides,
155
152
  }
package/src/config.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
 
3
- import type { GitFileListMode, SessionBarPosition, WorkspaceSnapshotV1 } from './state/types'
3
+ import type { GitFileListMode, WorkspaceSnapshotV1 } from './state/types'
4
4
 
5
5
  import { logDebug } from './debug/input-log'
6
6
  import { getProfileConfigDir } from './profile-paths'
@@ -41,7 +41,6 @@ export interface AimuxConfig {
41
41
  gitPane?: PersistedGitPane
42
42
  sidebar?: PersistedSidebar
43
43
  sessionBarVisible?: boolean
44
- sessionBarPosition?: SessionBarPosition
45
44
  workspaceSnapshot?: WorkspaceSnapshotV1
46
45
  skippedUpdateVersion?: string
47
46
  }
@@ -161,7 +160,6 @@ export function loadConfigResult(): ConfigLoadResult {
161
160
  gitPanelVisible?: unknown
162
161
  gitPanelRatio?: unknown
163
162
  sessionBarVisible?: unknown
164
- sessionBarPosition?: unknown
165
163
  workspaceSnapshot?: unknown
166
164
  skippedUpdateVersion?: unknown
167
165
  }
@@ -233,14 +231,6 @@ export function loadConfigResult(): ConfigLoadResult {
233
231
  issues.push('ignored invalid sessionBarVisible')
234
232
  }
235
233
 
236
- const validSessionBarPosition =
237
- parsed.sessionBarPosition === 'top' || parsed.sessionBarPosition === 'bottom'
238
- ? parsed.sessionBarPosition
239
- : undefined
240
- if (parsed.sessionBarPosition !== undefined && validSessionBarPosition === undefined) {
241
- issues.push('ignored invalid sessionBarPosition')
242
- }
243
-
244
234
  if (
245
235
  parsed.workspaceSnapshot !== undefined &&
246
236
  !isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
@@ -264,7 +254,6 @@ export function loadConfigResult(): ConfigLoadResult {
264
254
  config: {
265
255
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
266
256
  gitPane: validGitPane,
267
- sessionBarPosition: validSessionBarPosition,
268
257
  sessionBarVisible: validSessionBarVisible,
269
258
  sidebar: validSidebar,
270
259
  skippedUpdateVersion: validSkippedUpdateVersion,
@@ -0,0 +1,54 @@
1
+ import { useEffect } from 'react'
2
+
3
+ import { appStore } from '../state/app-store'
4
+ import { dispatchGlobal } from '../state/dispatch-ref'
5
+ import { getCurrentBranch } from '../ui/git-branch'
6
+
7
+ const INTERVAL_MS = 4000
8
+
9
+ // Single source of truth for "what git branch is each worktree on right now".
10
+ // Polls every known worktree's path and dispatches update-worktree-record
11
+ // when the live branch differs from the stored one. Components read
12
+ // `worktree.branch` from state — no per-component polling, no flickers.
13
+ //
14
+ // Runs once when enabled; reads sessions from the store on each tick so
15
+ // session updates do NOT re-create the effect (which would otherwise feedback
16
+ // off our own dispatches).
17
+ export function useWorktreeBranchPolling(enabled: boolean): void {
18
+ useEffect(() => {
19
+ if (!enabled) return
20
+
21
+ let cancelled = false
22
+ let timer: ReturnType<typeof setTimeout> | null = null
23
+
24
+ const tick = async () => {
25
+ const sessions = appStore.getState().sessions
26
+ await Promise.all(
27
+ sessions.flatMap((session) =>
28
+ (session.worktrees ?? []).map(async (worktree) => {
29
+ if (worktree.path == null || worktree.path === '') return
30
+ const branch = await getCurrentBranch(worktree.path)
31
+ if (cancelled) return
32
+ if (branch != null && branch !== worktree.branch) {
33
+ dispatchGlobal({
34
+ patch: { branch },
35
+ sessionId: session.id,
36
+ type: 'update-worktree-record',
37
+ worktreeId: worktree.id,
38
+ })
39
+ }
40
+ })
41
+ )
42
+ )
43
+ if (cancelled) return
44
+ timer = setTimeout(() => void tick(), INTERVAL_MS)
45
+ }
46
+
47
+ void tick()
48
+
49
+ return () => {
50
+ cancelled = true
51
+ if (timer != null) clearTimeout(timer)
52
+ }
53
+ }, [enabled])
54
+ }
@@ -68,6 +68,8 @@ export type SideEffect =
68
68
  | { type: 'git-push' }
69
69
  | { type: 'confirm-update-selection' }
70
70
  | { type: 'switch-session-by-index'; index: number }
71
+ | { type: 'cycle-sidebar-item'; direction: 1 | -1 }
72
+ | { type: 'switch-tab-by-index'; index: number }
71
73
  | { type: 'delete-session'; sessionId: string }
72
74
  | { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
73
75
  | {
@@ -124,6 +124,116 @@ export function allLeafIds(tree: LayoutNode): string[] {
124
124
  return [...allLeafIds(tree.first), ...allLeafIds(tree.second)]
125
125
  }
126
126
 
127
+ export type BoundarySide = 'left' | 'right' | 'top' | 'bottom'
128
+
129
+ export function getBoundaryLeafIds(node: LayoutNode, side: BoundarySide): string[] {
130
+ if (node.type === 'leaf') {
131
+ return [node.tabId]
132
+ }
133
+
134
+ const axisOfSide = side === 'left' || side === 'right' ? 'vertical' : 'horizontal'
135
+
136
+ if (node.direction === axisOfSide) {
137
+ // Split direction matches the side's axis: only one child touches `side`.
138
+ const child = side === 'left' || side === 'top' ? node.first : node.second
139
+ return getBoundaryLeafIds(child, side)
140
+ }
141
+
142
+ // Perpendicular split: both children touch `side`.
143
+ return [...getBoundaryLeafIds(node.first, side), ...getBoundaryLeafIds(node.second, side)]
144
+ }
145
+
146
+ export interface JunctionEdgeInfo {
147
+ tabId: string
148
+ direction: SplitDirection
149
+ screenStart: number
150
+ totalSize: number
151
+ }
152
+
153
+ export interface JunctionEdges {
154
+ left?: JunctionEdgeInfo
155
+ right?: JunctionEdgeInfo
156
+ top?: JunctionEdgeInfo
157
+ bottom?: JunctionEdgeInfo
158
+ }
159
+
160
+ function firstLeafIdOf(node: LayoutNode): string {
161
+ return node.type === 'leaf' ? node.tabId : firstLeafIdOf(node.first)
162
+ }
163
+
164
+ export function computeJunctionEdges(
165
+ tree: LayoutNode,
166
+ bounds: PaneRect,
167
+ origin: { x: number; y: number }
168
+ ): Map<string, JunctionEdges> {
169
+ const result = new Map<string, JunctionEdges>()
170
+ walk(tree, bounds)
171
+ return result
172
+
173
+ function ensure(tabId: string): JunctionEdges {
174
+ let entry = result.get(tabId)
175
+ if (!entry) {
176
+ entry = {}
177
+ result.set(tabId, entry)
178
+ }
179
+ return entry
180
+ }
181
+
182
+ function walk(node: LayoutNode, nodeBounds: PaneRect): void {
183
+ if (node.type === 'leaf') {
184
+ ensure(node.tabId)
185
+ return
186
+ }
187
+
188
+ const info: JunctionEdgeInfo = {
189
+ direction: node.direction,
190
+ screenStart:
191
+ node.direction === 'vertical' ? origin.x + nodeBounds.x : origin.y + nodeBounds.y,
192
+ tabId: firstLeafIdOf(node.first),
193
+ totalSize: node.direction === 'vertical' ? nodeBounds.cols : nodeBounds.rows,
194
+ }
195
+
196
+ if (node.direction === 'vertical') {
197
+ for (const leafId of getBoundaryLeafIds(node.first, 'right')) {
198
+ ensure(leafId).right = info
199
+ }
200
+ for (const leafId of getBoundaryLeafIds(node.second, 'left')) {
201
+ ensure(leafId).left = info
202
+ }
203
+ } else {
204
+ for (const leafId of getBoundaryLeafIds(node.first, 'bottom')) {
205
+ ensure(leafId).bottom = info
206
+ }
207
+ for (const leafId of getBoundaryLeafIds(node.second, 'top')) {
208
+ ensure(leafId).top = info
209
+ }
210
+ }
211
+
212
+ // Recurse with bounds math matching computePaneRects (now without separator gap).
213
+ if (node.direction === 'vertical') {
214
+ const firstCols = Math.max(1, Math.floor(nodeBounds.cols * node.ratio))
215
+ const secondCols = Math.max(1, nodeBounds.cols - firstCols)
216
+ walk(node.first, { cols: firstCols, rows: nodeBounds.rows, x: nodeBounds.x, y: nodeBounds.y })
217
+ walk(node.second, {
218
+ cols: secondCols,
219
+ rows: nodeBounds.rows,
220
+ x: nodeBounds.x + firstCols,
221
+ y: nodeBounds.y,
222
+ })
223
+ } else {
224
+ const firstRows = Math.max(1, Math.floor(nodeBounds.rows * node.ratio))
225
+ const secondRows = Math.max(1, nodeBounds.rows - firstRows)
226
+ walk(node.first, { cols: nodeBounds.cols, rows: firstRows, x: nodeBounds.x, y: nodeBounds.y })
227
+ walk(node.second, {
228
+ cols: nodeBounds.cols,
229
+ rows: secondRows,
230
+ x: nodeBounds.x,
231
+ y: nodeBounds.y + firstRows,
232
+ })
233
+ }
234
+ }
235
+ }
236
+
127
237
  export function pruneLayoutTree(tree: LayoutNode, validTabIds: Set<string>): LayoutNode | null {
128
238
  if (tree.type === 'leaf') {
129
239
  return validTabIds.has(tree.tabId) ? tree : null
@@ -227,13 +337,13 @@ export function computePaneRects(tree: LayoutNode, bounds: PaneRect): Map<string
227
337
  if (tree.direction === 'vertical') {
228
338
  // Split left/right
229
339
  const firstCols = Math.max(1, Math.floor(bounds.cols * tree.ratio))
230
- const secondCols = Math.max(1, bounds.cols - firstCols - 1) // -1 for separator
340
+ const secondCols = Math.max(1, bounds.cols - firstCols)
231
341
 
232
342
  const firstBounds: PaneRect = { cols: firstCols, rows: bounds.rows, x: bounds.x, y: bounds.y }
233
343
  const secondBounds: PaneRect = {
234
344
  cols: secondCols,
235
345
  rows: bounds.rows,
236
- x: bounds.x + firstCols + 1,
346
+ x: bounds.x + firstCols,
237
347
  y: bounds.y,
238
348
  }
239
349
 
@@ -246,14 +356,14 @@ export function computePaneRects(tree: LayoutNode, bounds: PaneRect): Map<string
246
356
  } else {
247
357
  // Split top/bottom
248
358
  const firstRows = Math.max(1, Math.floor(bounds.rows * tree.ratio))
249
- const secondRows = Math.max(1, bounds.rows - firstRows - 1) // -1 for separator
359
+ const secondRows = Math.max(1, bounds.rows - firstRows)
250
360
 
251
361
  const firstBounds: PaneRect = { cols: bounds.cols, rows: firstRows, x: bounds.x, y: bounds.y }
252
362
  const secondBounds: PaneRect = {
253
363
  cols: bounds.cols,
254
364
  rows: secondRows,
255
365
  x: bounds.x,
256
- y: bounds.y + firstRows + 1,
366
+ y: bounds.y + firstRows,
257
367
  }
258
368
 
259
369
  for (const [id, rect] of computePaneRects(tree.first, firstBounds)) {
@@ -1,5 +1,6 @@
1
1
  import type { AppAction, AppState } from '../types'
2
2
 
3
+ import { moveIdToIdPosition, orderSessionsForDisplay } from '../../ui/session-ordering'
3
4
  import { filterSessions } from '../selectors'
4
5
  import { restoreWorkspaceState } from '../session-persistence'
5
6
  import { withActiveWorktree } from '../session-worktrees'
@@ -107,6 +108,25 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
107
108
  }
108
109
  return { ...state, sessions: ordered }
109
110
  }
111
+ case 'reorder-active-session': {
112
+ const currentId = state.currentSessionId
113
+ if (currentId == null || currentId === '') return state
114
+ const ids = orderSessionsForDisplay(state.sessions).map((s) => s.id)
115
+ const from = ids.indexOf(currentId)
116
+ if (from < 0) return state
117
+ const to = from + action.delta
118
+ const targetId = ids[to]
119
+ if (targetId == null) return state
120
+ const nextIds = moveIdToIdPosition(ids, currentId, targetId)
121
+ const byId = new Map(state.sessions.map((s) => [s.id, s]))
122
+ const nextSessions = nextIds.map((id, idx) => {
123
+ const s = byId.get(id)
124
+ return s ? { ...s, order: idx } : s
125
+ })
126
+ const filtered = nextSessions.filter((s): s is NonNullable<typeof s> => s != null)
127
+ if (filtered.length !== state.sessions.length) return state
128
+ return { ...state, sessions: filtered }
129
+ }
110
130
  case 'set-session-status': {
111
131
  const prev = state.sessionStatuses[action.sessionId]
112
132
  if (
@@ -199,7 +199,11 @@ function getCurrentSession(state: AppState) {
199
199
  : undefined
200
200
  }
201
201
 
202
- function withActiveTabWorktree(state: AppState, tabId: string | null): AppState {
202
+ function withActiveTabWorktree(
203
+ state: AppState,
204
+ tabId: string | null,
205
+ opts?: { onlyIfMissing?: boolean }
206
+ ): AppState {
203
207
  if (
204
208
  tabId == null ||
205
209
  tabId === '' ||
@@ -209,6 +213,19 @@ function withActiveTabWorktree(state: AppState, tabId: string | null): AppState
209
213
  const tab = state.tabs.find((entry) => entry.id === tabId)
210
214
  if (!(tab?.worktreeId != null && tab?.worktreeId !== '')) return state
211
215
  const worktreeId = tab.worktreeId
216
+ // When `onlyIfMissing` is set, we leave the session's worktree alone if it
217
+ // already points at a known worktree. Used by `hydrate-workspace` so that
218
+ // a backend re-attach after a user-initiated worktree switch (j/k cycling)
219
+ // doesn't clobber the freshly chosen worktree by re-syncing from the
220
+ // restored active tab.
221
+ if (opts?.onlyIfMissing === true) {
222
+ const session = state.sessions.find((entry) => entry.id === state.currentSessionId)
223
+ const hasValidWorktree =
224
+ session?.activeWorktreeId != null &&
225
+ session.activeWorktreeId !== '' &&
226
+ (session.worktrees?.some((w) => w.id === session.activeWorktreeId) ?? false)
227
+ if (hasValidWorktree) return state
228
+ }
212
229
  return {
213
230
  ...state,
214
231
  sessions: state.sessions.map((session) =>
@@ -345,7 +362,8 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
345
362
  tabGroupMap: hydratedGroupMap,
346
363
  tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
347
364
  },
348
- hydratedActiveTabId
365
+ hydratedActiveTabId,
366
+ { onlyIfMissing: true }
349
367
  )
350
368
  }
351
369
  case 'close-tab':
@@ -34,9 +34,6 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
34
34
  ...state,
35
35
  sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
36
36
  }
37
- case 'set-session-bar-position':
38
- if (state.sessionBar.position === action.position) return state
39
- return { ...state, sessionBar: { ...state.sessionBar, position: action.position } }
40
37
  default:
41
38
  return null
42
39
  }
@@ -3,13 +3,21 @@ import { createHash } from 'node:crypto'
3
3
  import { existsSync } from 'node:fs'
4
4
  import { basename } from 'node:path'
5
5
 
6
- import type { SessionRecord, TabSession, WorktreeRecord } from './types'
6
+ import type { BranchDivergence, SessionRecord, TabSession, WorktreeRecord } from './types'
7
7
 
8
8
  import { createPrefixedId } from '../platform/id'
9
9
  import { isInsideAimuxWorktreeRoot } from '../platform/worktree-paths'
10
10
 
11
11
  const WORKTREE_COLORS = ['#7dd3fc', '#86efac', '#facc15', '#f0abfc', '#fb7185', '#c4b5fd']
12
12
 
13
+ export function formatDivergence(divergence: BranchDivergence | undefined): string {
14
+ if (divergence == null) return ''
15
+ const parts: string[] = []
16
+ if (divergence.ahead > 0) parts.push(`↑${divergence.ahead}`)
17
+ if (divergence.behind > 0) parts.push(`↓${divergence.behind}`)
18
+ return parts.join(' ')
19
+ }
20
+
13
21
  export function getWorktreeColor(worktreeId: string): string {
14
22
  let hash = 0
15
23
  for (let i = 0; i < worktreeId.length; i++) {
@@ -98,7 +106,24 @@ function mergeExistingGitWorktrees(worktrees: WorktreeRecord[], now: string): Wo
98
106
  ? primaryRepoRoot
99
107
  : (live[0]?.path ?? anchor.repoRoot)
100
108
  for (const entry of live) {
101
- if (byPath.has(entry.path)) continue
109
+ const existing = byPath.get(entry.path)
110
+ if (existing) {
111
+ // Patch branch / commitSha from git for entries we already track —
112
+ // notably the primary worktree, whose `branch` is never set at creation
113
+ // time (createPrimaryWorktree doesn't run `git`). Without this, the UI
114
+ // and the divergence poller have no branch for primary worktrees.
115
+ const patchedBranch = entry.branch ?? existing.branch
116
+ const patchedSha = entry.head ?? existing.commitSha
117
+ if (patchedBranch !== existing.branch || patchedSha !== existing.commitSha) {
118
+ byPath.set(entry.path, {
119
+ ...existing,
120
+ branch: patchedBranch,
121
+ commitSha: patchedSha,
122
+ updatedAt: now,
123
+ })
124
+ }
125
+ continue
126
+ }
102
127
  if (isInsideAimuxWorktreeRoot(entry.path)) continue
103
128
  const worktree: WorktreeRecord = {
104
129
  branch: entry.branch,
@@ -204,6 +229,24 @@ export function getRenderedTabWorktreeId(
204
229
  return tab.worktreeId ?? worktrees[0]?.id ?? '__main__'
205
230
  }
206
231
 
232
+ export function filterTabsForActiveWorktree(
233
+ tabs: TabSession[],
234
+ session: SessionRecord | undefined
235
+ ): TabSession[] {
236
+ if (!session) return tabs
237
+ const activeWorktreeId = session.activeWorktreeId
238
+ if (activeWorktreeId == null || activeWorktreeId === '') return tabs
239
+ const worktrees = session.worktrees ?? []
240
+ const primaryId = worktrees[0]?.id
241
+ const activeIsPrimary = primaryId != null && primaryId === activeWorktreeId
242
+ return tabs.filter((tab) => {
243
+ const owned = tab.worktreeId != null && tab.worktreeId !== ''
244
+ if (owned) return tab.worktreeId === activeWorktreeId
245
+ // Unbound (legacy) tabs surface only under the primary worktree.
246
+ return activeIsPrimary
247
+ })
248
+ }
249
+
207
250
  export function orderTabsByWorktree(
208
251
  tabs: TabSession[],
209
252
  session: SessionRecord | undefined