@brimveyn/aimux 1.13.2 → 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 (54) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-attach-runtime.ts +1 -3
  3. package/src/app-runtime/multi-click-clipboard-guard.ts +24 -12
  4. package/src/app-runtime/pty-write.ts +6 -9
  5. package/src/app-runtime/side-effects.ts +149 -19
  6. package/src/app-runtime/snippet-actions.ts +2 -3
  7. package/src/app-runtime/split-drag-controller.ts +3 -1
  8. package/src/app-runtime/use-backend-runtime.ts +4 -7
  9. package/src/app-runtime/use-mouse-handlers.ts +30 -1
  10. package/src/app-runtime/use-renderer-bindings.ts +4 -12
  11. package/src/app-runtime/use-terminal-resize.ts +7 -22
  12. package/src/app.tsx +0 -6
  13. package/src/config.ts +1 -12
  14. package/src/daemon/daemon.ts +2 -19
  15. package/src/daemon/session-manager.ts +3 -15
  16. package/src/daemon/session-registry.ts +11 -30
  17. package/src/git/worktree-branch-poller.ts +54 -0
  18. package/src/input/modes/types.ts +2 -0
  19. package/src/input/terminal-text-extraction.ts +7 -8
  20. package/src/ipc/manager-protocol.ts +6 -38
  21. package/src/ipc/protocol.ts +9 -37
  22. package/src/pty/pty-manager.ts +20 -31
  23. package/src/pty/terminal-snapshot.ts +43 -0
  24. package/src/session-backend/local-session-backend.ts +7 -31
  25. package/src/session-backend/remote-session-backend.ts +5 -32
  26. package/src/session-backend/types.ts +2 -15
  27. package/src/state/layout-tree.ts +114 -4
  28. package/src/state/reducers/session-state.ts +20 -0
  29. package/src/state/reducers/tab-state.ts +22 -22
  30. package/src/state/reducers/ui-state.ts +0 -3
  31. package/src/state/session-persistence.ts +2 -22
  32. package/src/state/session-worktrees.ts +45 -2
  33. package/src/state/store.ts +0 -3
  34. package/src/state/tab-entries.ts +74 -0
  35. package/src/state/types.ts +4 -15
  36. package/src/state/validation.ts +0 -8
  37. package/src/state/workspace-save.ts +0 -1
  38. package/src/terminal-manager/manager-client.ts +5 -29
  39. package/src/terminal-manager/terminal-manager.ts +2 -17
  40. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  41. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  42. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  43. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  44. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  45. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  46. package/src/ui/components/layout/split-layout.tsx +20 -39
  47. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  48. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  49. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  50. package/src/ui/root.tsx +68 -63
  51. package/src/ui/components/layout/session-bar.tsx +0 -296
  52. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  53. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  54. package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
@@ -85,6 +85,17 @@ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): Termin
85
85
  }
86
86
  }
87
87
 
88
+ // Read the scroll position straight from the emulator that owns it. This is the
89
+ // single source of truth for re-anchoring across a resize: deriving it here
90
+ // (zero latency) instead of accepting a frontend-supplied intent avoids the
91
+ // stale-mirror drift that desynced selection/copy from the rendered viewport.
92
+ function deriveEmulatorScrollIntent(emulator: XTerm): ScrollIntent {
93
+ const buffer = emulator.buffer.active
94
+ return buffer.viewportY >= buffer.baseY
95
+ ? { kind: 'bottom' }
96
+ : { absoluteLine: buffer.viewportY, kind: 'anchor' }
97
+ }
98
+
88
99
  function envInt(name: string, fallback: number): number {
89
100
  const raw = process.env[name]
90
101
  if (raw === undefined) return fallback
@@ -374,13 +385,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
374
385
  session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
375
386
  }
376
387
 
377
- private applyResize(
378
- session: SessionHandle,
379
- cols: number,
380
- rows: number,
381
- intent: ScrollIntent | undefined,
382
- sync: boolean
383
- ): void {
388
+ private applyResize(session: SessionHandle, cols: number, rows: number, sync: boolean): void {
389
+ // Capture the scroll position from the emulator *before* reflow, so the
390
+ // re-anchor restores where the user actually was. The frontend no longer
391
+ // supplies an intent — the backend owns scroll position end to end.
392
+ const intent = deriveEmulatorScrollIntent(session.emulator)
384
393
  const safeCols = Math.max(20, cols)
385
394
  const safeRows = Math.max(8, rows)
386
395
  session.pty.resize(safeCols, safeRows)
@@ -406,38 +415,18 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
406
415
  }
407
416
  }
408
417
 
409
- resizeAll(
410
- cols: number,
411
- rows: number,
412
- intents?: Map<string, ScrollIntent>,
413
- options?: { sync?: boolean }
414
- ): void {
418
+ resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
415
419
  for (const session of this.sessions.values()) {
416
- this.applyResize(session, cols, rows, intents?.get(session.tabId), options?.sync ?? false)
417
- }
418
- }
419
-
420
- resizeSession(
421
- tabId: string,
422
- cols: number,
423
- rows: number,
424
- intent?: ScrollIntent,
425
- options?: { sync?: boolean }
426
- ): void {
427
- const session = this.sessions.get(tabId)
428
- if (!session) {
429
- return
420
+ this.applyResize(session, cols, rows, options?.sync ?? false)
430
421
  }
431
- this.applyResize(session, cols, rows, intent, options?.sync ?? false)
432
422
  }
433
423
 
434
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
424
+ resizeSession(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
435
425
  const session = this.sessions.get(tabId)
436
426
  if (!session) {
437
427
  return
438
428
  }
439
- this.applyScrollIntent(session, intent)
440
- this.scheduleRender(session)
429
+ this.applyResize(session, cols, rows, options?.sync ?? false)
441
430
  }
442
431
 
443
432
  disposeSession(tabId: string): void {
@@ -100,6 +100,7 @@ function buildLine(
100
100
 
101
101
  const cell = terminal.buffer.active.getNullCell()
102
102
  const spans: TerminalSpan[] = []
103
+ let visualColumns = 0
103
104
 
104
105
  for (let column = 0; column < terminal.cols; column += 1) {
105
106
  const current = line.getCell(column, cell)
@@ -138,6 +139,48 @@ function buildLine(
138
139
  text,
139
140
  underline: current.isUnderline() ? true : undefined,
140
141
  })
142
+
143
+ visualColumns += current.getWidth()
144
+ }
145
+
146
+ // Pad the row to the full terminal width so opentui overwrites every cell.
147
+ // Without padding, cells past the last written character retain content
148
+ // from the previous frame (opentui's `blendCells` preserves the existing
149
+ // char when an overlay space lands on it — see modal-shell.tsx for the
150
+ // same class of bug solved differently for transparent modals).
151
+ if (visualColumns < terminal.cols) {
152
+ const padCount = terminal.cols - visualColumns
153
+ const cursorInPad =
154
+ cursorVisible &&
155
+ cursorColumn !== null &&
156
+ cursorColumn >= visualColumns &&
157
+ cursorColumn < terminal.cols
158
+
159
+ if (cursorInPad && cursorColumn !== null) {
160
+ // cursorColumn is a buffer column while visualColumns is a sum of cell
161
+ // widths; they diverge when wide glyphs precede the gap. Clamp the offset
162
+ // into the pad so leading + cursor + trailing always equals padCount and
163
+ // the row never over/undershoots terminal.cols (cursor may be a column
164
+ // off in pathological wide-char rows, but the row width stays correct).
165
+ const cursorOffset = Math.min(cursorColumn - visualColumns, padCount - 1)
166
+ const leading = cursorOffset
167
+ const trailing = padCount - cursorOffset - 1
168
+ if (leading > 0) {
169
+ pushSpan(spans, { text: ' '.repeat(leading) })
170
+ }
171
+ const tokens = getCurrentTheme()
172
+ pushSpan(spans, {
173
+ bg: tokens.text,
174
+ cursor: true,
175
+ fg: tokens.background,
176
+ text: ' ',
177
+ })
178
+ if (trailing > 0) {
179
+ pushSpan(spans, { text: ' '.repeat(trailing) })
180
+ }
181
+ } else {
182
+ pushSpan(spans, { text: ' '.repeat(padCount) })
183
+ }
141
184
  }
142
185
 
143
186
  return { spans }
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
 
3
- import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
3
+ import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
4
4
  import type { SessionBackend, SessionBackendEvents } from './types'
5
5
 
6
6
  import { SessionManager } from '../daemon/session-manager'
@@ -12,7 +12,6 @@ import {
12
12
  getSnapshotTrees,
13
13
  toTerminalContentSize,
14
14
  } from '../state/layout-resize'
15
- import { getSnapshotScrollIntents } from '../state/session-persistence'
16
15
 
17
16
  export class LocalSessionBackend
18
17
  extends EventEmitter<SessionBackendEvents>
@@ -67,22 +66,15 @@ export class LocalSessionBackend
67
66
  })
68
67
  this.currentSessionId = options.sessionId
69
68
  const trees = getSnapshotTrees(options.workspaceSnapshot)
70
- const intents = getSnapshotScrollIntents(options.workspaceSnapshot)
71
69
  const splitTrees = trees.filter((t) => t.type === 'split')
72
70
  if (splitTrees.length > 0) {
73
71
  const bounds = createTerminalBounds(options.cols, options.rows)
74
72
  forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
75
73
  const size = toTerminalContentSize(rect)
76
- this.sessionManager.resizeTab(
77
- options.sessionId,
78
- tabId,
79
- size.cols,
80
- size.rows,
81
- intents.get(tabId)
82
- )
74
+ this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
83
75
  })
84
76
  } else {
85
- this.sessionManager.resize(options.sessionId, options.cols, options.rows, intents)
77
+ this.sessionManager.resize(options.sessionId, options.cols, options.rows)
86
78
  }
87
79
  const attachResult = this.sessionManager.attachSession(
88
80
  options.sessionId,
@@ -149,36 +141,20 @@ export class LocalSessionBackend
149
141
  this.sessionManager.scrollToBottom(this.currentSessionId, tabId)
150
142
  }
151
143
 
152
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
153
- if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
154
- this.sessionManager.reapplyScrollIntent(this.currentSessionId, tabId, intent)
155
- }
156
-
157
144
  setActiveTab(tabId: string | null): void {
158
145
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
159
146
  logDebug('backend.local.setActiveTab', { sessionId: this.currentSessionId, tabId })
160
147
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
161
148
  }
162
149
 
163
- resizeAll(
164
- cols: number,
165
- rows: number,
166
- intents?: Map<string, ScrollIntent>,
167
- options?: { sync?: boolean }
168
- ): void {
150
+ resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void {
169
151
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
170
- this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
152
+ this.sessionManager.resize(this.currentSessionId, cols, rows, options)
171
153
  }
172
154
 
173
- resizeTab(
174
- tabId: string,
175
- cols: number,
176
- rows: number,
177
- intent?: ScrollIntent,
178
- options?: { sync?: boolean }
179
- ): void {
155
+ resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void {
180
156
  if (!(this.currentSessionId != null && this.currentSessionId !== '')) return
181
- this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
157
+ this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, options)
182
158
  }
183
159
 
184
160
  disposeSession(tabId: string): void {
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events'
2
2
  import { connect, type Socket } from 'node:net'
3
3
 
4
- import type { AssistantId, ScrollIntent, WorkspaceSnapshotV1 } from '../state/types'
4
+ import type { AssistantId, WorkspaceSnapshotV1 } from '../state/types'
5
5
  import type { SessionBackend, SessionBackendEvents } from './types'
6
6
 
7
7
  import { getIpcDaemonSocketPath } from '../daemon/runtime-paths'
@@ -404,17 +404,6 @@ export class RemoteSessionBackend
404
404
  )
405
405
  }
406
406
 
407
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
408
- if (!this.attached) {
409
- return
410
- }
411
- this.dispatchCommand(
412
- { id: crypto.randomUUID(), payload: { intent, tabId }, type: 'reapplyScrollIntent' },
413
- 'reapplyScrollIntent',
414
- tabId
415
- )
416
- }
417
-
418
407
  setActiveTab(tabId: string | null): void {
419
408
  if (!this.attached) {
420
409
  return
@@ -425,40 +414,24 @@ export class RemoteSessionBackend
425
414
  )
426
415
  }
427
416
 
428
- resizeAll(
429
- cols: number,
430
- rows: number,
431
- intents?: Map<string, ScrollIntent>,
432
- _options?: { sync?: boolean }
433
- ): void {
417
+ resizeAll(cols: number, rows: number, _options?: { sync?: boolean }): void {
434
418
  if (!this.attached) {
435
419
  logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
436
420
  return
437
421
  }
438
422
  logDebug('backend.remote.resize', { cols, rows, sessionId: this.currentSessionId })
439
- const intentsRecord = intents ? Object.fromEntries(intents.entries()) : undefined
440
423
  this.dispatchCommand(
441
- {
442
- id: crypto.randomUUID(),
443
- payload: { cols, intents: intentsRecord, rows },
444
- type: 'resizeClient',
445
- },
424
+ { id: crypto.randomUUID(), payload: { cols, rows }, type: 'resizeClient' },
446
425
  'resizeClient'
447
426
  )
448
427
  }
449
428
 
450
- resizeTab(
451
- tabId: string,
452
- cols: number,
453
- rows: number,
454
- intent?: ScrollIntent,
455
- _options?: { sync?: boolean }
456
- ): void {
429
+ resizeTab(tabId: string, cols: number, rows: number, _options?: { sync?: boolean }): void {
457
430
  if (!this.attached) {
458
431
  return
459
432
  }
460
433
  this.dispatchCommand(
461
- { id: crypto.randomUUID(), payload: { cols, intent, rows, tabId }, type: 'resizeTab' },
434
+ { id: crypto.randomUUID(), payload: { cols, rows, tabId }, type: 'resizeTab' },
462
435
  'resizeTab',
463
436
  tabId
464
437
  )
@@ -1,7 +1,6 @@
1
1
  import type { EventEmitter } from 'node:events'
2
2
 
3
3
  import type {
4
- ScrollIntent,
5
4
  SessionStatus,
6
5
  TabActivity,
7
6
  TabSession,
@@ -49,21 +48,9 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
49
48
  write(tabId: string, input: string): void
50
49
  scrollViewport(tabId: string, deltaLines: number): void
51
50
  scrollViewportToBottom(tabId: string): void
52
- reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
53
51
  setActiveTab(tabId: string | null): void
54
- resizeAll(
55
- cols: number,
56
- rows: number,
57
- intents?: Map<string, ScrollIntent>,
58
- options?: { sync?: boolean }
59
- ): void
60
- resizeTab(
61
- tabId: string,
62
- cols: number,
63
- rows: number,
64
- intent?: ScrollIntent,
65
- options?: { sync?: boolean }
66
- ): void
52
+ resizeAll(cols: number, rows: number, options?: { sync?: boolean }): void
53
+ resizeTab(tabId: string, cols: number, rows: number, options?: { sync?: boolean }): void
67
54
  disposeSession(tabId: string): void
68
55
  disposeAll(): void
69
56
  destroy(keepSessions?: boolean): Promise<void> | void
@@ -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 (
@@ -1,3 +1,5 @@
1
+ import type { AppAction, AppState, TabSession } from '../types'
2
+
1
3
  import {
2
4
  allLeafIds,
3
5
  createGroupId,
@@ -15,13 +17,6 @@ import {
15
17
  import { normalizeGroupedTabOrder } from '../session-persistence'
16
18
  import { orderTabsByWorktree, withActiveWorktree } from '../session-worktrees'
17
19
  import { createDefaultTerminalModes } from '../terminal-modes'
18
- import {
19
- type AppAction,
20
- type AppState,
21
- DEFAULT_SCROLL_INTENT,
22
- deriveScrollIntent,
23
- type TabSession,
24
- } from '../types'
25
20
 
26
21
  const MAX_BUFFER_LENGTH = 50_000
27
22
 
@@ -204,7 +199,11 @@ function getCurrentSession(state: AppState) {
204
199
  : undefined
205
200
  }
206
201
 
207
- function withActiveTabWorktree(state: AppState, tabId: string | null): AppState {
202
+ function withActiveTabWorktree(
203
+ state: AppState,
204
+ tabId: string | null,
205
+ opts?: { onlyIfMissing?: boolean }
206
+ ): AppState {
208
207
  if (
209
208
  tabId == null ||
210
209
  tabId === '' ||
@@ -214,6 +213,19 @@ function withActiveTabWorktree(state: AppState, tabId: string | null): AppState
214
213
  const tab = state.tabs.find((entry) => entry.id === tabId)
215
214
  if (!(tab?.worktreeId != null && tab?.worktreeId !== '')) return state
216
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
+ }
217
229
  return {
218
230
  ...state,
219
231
  sessions: state.sessions.map((session) =>
@@ -350,7 +362,8 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
350
362
  tabGroupMap: hydratedGroupMap,
351
363
  tabs: normalizeGroupedTabOrder(action.tabs, hydratedTrees, hydratedGroupMap),
352
364
  },
353
- hydratedActiveTabId
365
+ hydratedActiveTabId,
366
+ { onlyIfMissing: true }
354
367
  )
355
368
  }
356
369
  case 'close-tab':
@@ -448,7 +461,6 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
448
461
  buffer: '',
449
462
  errorMessage: undefined,
450
463
  exitCode: undefined,
451
- scrollIntent: DEFAULT_SCROLL_INTENT,
452
464
  status: 'starting',
453
465
  terminalModes: createDefaultTerminalModes(),
454
466
  viewport: undefined,
@@ -470,23 +482,11 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
470
482
  ...state,
471
483
  tabs: updateTab(state.tabs, action.tabId, (tab) => ({
472
484
  ...tab,
473
- scrollIntent:
474
- action.source === 'resize' || action.source === 'switch'
475
- ? (tab.scrollIntent ?? DEFAULT_SCROLL_INTENT)
476
- : deriveScrollIntent(action.viewport),
477
485
  status: tab.status === 'starting' ? 'running' : tab.status,
478
486
  terminalModes: action.terminalModes,
479
487
  viewport: action.viewport,
480
488
  })),
481
489
  }
482
- case 'set-scroll-intent':
483
- return {
484
- ...state,
485
- tabs: updateTab(state.tabs, action.tabId, (tab) => ({
486
- ...tab,
487
- scrollIntent: action.intent,
488
- })),
489
- }
490
490
  case 'set-tab-activity':
491
491
  return {
492
492
  ...state,
@@ -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
  }
@@ -1,12 +1,6 @@
1
+ import type { AppState, TabSession, TabStatus, WorkspaceSnapshotV1 } from './types'
2
+
1
3
  import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './layout-tree'
2
- import {
3
- type AppState,
4
- DEFAULT_SCROLL_INTENT,
5
- type ScrollIntent,
6
- type TabSession,
7
- type TabStatus,
8
- type WorkspaceSnapshotV1,
9
- } from './types'
10
4
 
11
5
  export function createEmptyWorkspaceSnapshot(): WorkspaceSnapshotV1 {
12
6
  return {
@@ -47,7 +41,6 @@ export function serializeWorkspace(state: AppState): WorkspaceSnapshotV1 {
47
41
  errorMessage: tab.errorMessage,
48
42
  exitCode: tab.exitCode,
49
43
  id: tab.id,
50
- scrollIntent: tab.scrollIntent,
51
44
  status: tab.status === 'disconnected' ? 'running' : tab.status,
52
45
  terminalModes: tab.terminalModes,
53
46
  title: tab.title,
@@ -76,7 +69,6 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
76
69
  errorMessage: tab.errorMessage,
77
70
  exitCode: tab.exitCode,
78
71
  id: tab.id,
79
- scrollIntent: tab.scrollIntent ?? DEFAULT_SCROLL_INTENT,
80
72
  status: getDisconnectedStatus(tab.status),
81
73
  terminalModes: tab.terminalModes,
82
74
  title: tab.title,
@@ -85,18 +77,6 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
85
77
  }))
86
78
  }
87
79
 
88
- export function getSnapshotScrollIntents(
89
- snapshot: WorkspaceSnapshotV1 | undefined
90
- ): Map<string, ScrollIntent> {
91
- if (!snapshot || snapshot.version !== 1) {
92
- return new Map()
93
- }
94
-
95
- return new Map(
96
- snapshot.tabs.map((tab) => [tab.id, tab.scrollIntent ?? DEFAULT_SCROLL_INTENT] as const)
97
- )
98
- }
99
-
100
80
  export function restoreLayoutTrees(
101
81
  snapshot: WorkspaceSnapshotV1 | undefined,
102
82
  tabs: TabSession[]