@brimveyn/aimux 1.3.1 → 1.4.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.
@@ -79,8 +79,98 @@ function getTerminalModes(emulator: XTerm, alternateScrollMode: boolean): Termin
79
79
  }
80
80
  }
81
81
 
82
+ function envInt(name: string, fallback: number): number {
83
+ const raw = process.env[name]
84
+ if (raw === undefined) return fallback
85
+ const parsed = Number(raw)
86
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback
87
+ }
88
+
89
+ const RENDER_COALESCE_MS = 16
90
+ const DATA_DEBOUNCE_MS = envInt('AIMUX_RENDER_DEBOUNCE_MS', 32)
91
+ const BURST_MAX_MS = envInt('AIMUX_RENDER_BURST_MS', 500)
92
+
82
93
  export class PtyManager extends EventEmitter<PtyManagerEvents> {
83
94
  private sessions = new Map<string, SessionHandle>()
95
+ private pendingFlushes = new Map<string, ReturnType<typeof setTimeout>>()
96
+ private pendingBurstCaps = new Map<string, ReturnType<typeof setTimeout>>()
97
+
98
+ private clearTimers(tabId: string): void {
99
+ const flush = this.pendingFlushes.get(tabId)
100
+ if (flush) {
101
+ clearTimeout(flush)
102
+ this.pendingFlushes.delete(tabId)
103
+ }
104
+ const burst = this.pendingBurstCaps.get(tabId)
105
+ if (burst) {
106
+ clearTimeout(burst)
107
+ this.pendingBurstCaps.delete(tabId)
108
+ }
109
+ }
110
+
111
+ private scheduleRender(session: SessionHandle): void {
112
+ if (this.pendingFlushes.has(session.tabId)) {
113
+ return
114
+ }
115
+ const timer = setTimeout(() => {
116
+ this.pendingFlushes.delete(session.tabId)
117
+ if (this.sessions.get(session.tabId) !== session) {
118
+ return
119
+ }
120
+ const burst = this.pendingBurstCaps.get(session.tabId)
121
+ if (burst) {
122
+ clearTimeout(burst)
123
+ this.pendingBurstCaps.delete(session.tabId)
124
+ }
125
+ this.emitRenderIfChanged(session)
126
+ }, RENDER_COALESCE_MS)
127
+ this.pendingFlushes.set(session.tabId, timer)
128
+ }
129
+
130
+ private scheduleDataRender(session: SessionHandle): void {
131
+ const existingFlush = this.pendingFlushes.get(session.tabId)
132
+ if (existingFlush) {
133
+ clearTimeout(existingFlush)
134
+ }
135
+ const flushTimer = setTimeout(() => {
136
+ this.pendingFlushes.delete(session.tabId)
137
+ if (this.sessions.get(session.tabId) !== session) {
138
+ return
139
+ }
140
+ if (session.pendingWrites > 0) {
141
+ this.scheduleDataRender(session)
142
+ return
143
+ }
144
+ const burst = this.pendingBurstCaps.get(session.tabId)
145
+ if (burst) {
146
+ clearTimeout(burst)
147
+ this.pendingBurstCaps.delete(session.tabId)
148
+ }
149
+ this.emitRenderIfChanged(session)
150
+ }, DATA_DEBOUNCE_MS)
151
+ this.pendingFlushes.set(session.tabId, flushTimer)
152
+
153
+ if (!this.pendingBurstCaps.has(session.tabId)) {
154
+ const burstTimer = setTimeout(() => {
155
+ this.pendingBurstCaps.delete(session.tabId)
156
+ if (this.sessions.get(session.tabId) !== session) {
157
+ return
158
+ }
159
+ const flush = this.pendingFlushes.get(session.tabId)
160
+ if (flush) {
161
+ clearTimeout(flush)
162
+ this.pendingFlushes.delete(session.tabId)
163
+ }
164
+ this.emitRenderIfChanged(session)
165
+ }, BURST_MAX_MS)
166
+ this.pendingBurstCaps.set(session.tabId, burstTimer)
167
+ }
168
+ }
169
+
170
+ private flushRenderNow(session: SessionHandle): void {
171
+ this.clearTimers(session.tabId)
172
+ this.emitRenderIfChanged(session)
173
+ }
84
174
 
85
175
  private emitRenderIfChanged(session: SessionHandle): void {
86
176
  const nextSnapshot = snapshotTerminal(session.emulator, session.cursorVisible)
@@ -116,7 +206,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
116
206
  }
117
207
 
118
208
  this.sessions.delete(session.tabId)
119
- this.emitRenderIfChanged(session)
209
+ this.flushRenderNow(session)
120
210
  session.emulator.dispose()
121
211
  logDebug('ptyManager.finalize', { exitCode, tabId: session.tabId })
122
212
  this.emit('exit', session.tabId, exitCode)
@@ -188,9 +278,10 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
188
278
  session.cursorVisible = trackedModes.cursorVisible
189
279
  session.pendingModeSequence = trackedModes.pendingSequence
190
280
  session.pendingWrites += 1
281
+ this.scheduleDataRender(session)
191
282
  emulator.write(data, () => {
192
283
  session.pendingWrites -= 1
193
- this.emitRenderIfChanged(session)
284
+ this.scheduleDataRender(session)
194
285
 
195
286
  if (session.pendingWrites === 0 && session.pendingExitCode !== null) {
196
287
  this.finalizeSession(session, session.pendingExitCode)
@@ -215,7 +306,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
215
306
 
216
307
  this.sessions.set(options.tabId, session)
217
308
  logDebug('ptyManager.create.success', { tabId: options.tabId })
218
- this.emitRenderIfChanged(session)
309
+ this.scheduleRender(session)
219
310
  } catch (error) {
220
311
  const message = error instanceof Error ? error.message : String(error)
221
312
  logDebug('ptyManager.create.error', { error: message, tabId: options.tabId })
@@ -224,7 +315,9 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
224
315
  }
225
316
 
226
317
  write(tabId: string, input: string): void {
227
- this.sessions.get(tabId)?.pty.write(input)
318
+ const session = this.sessions.get(tabId)
319
+ if (!session) return
320
+ session.pty.write(input)
228
321
  }
229
322
 
230
323
  scrollViewport(tabId: string, deltaLines: number): void {
@@ -234,7 +327,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
234
327
  }
235
328
 
236
329
  session.emulator.scrollLines(deltaLines)
237
- this.emitRenderIfChanged(session)
330
+ this.scheduleRender(session)
238
331
  }
239
332
 
240
333
  scrollViewportToBottom(tabId: string): void {
@@ -244,7 +337,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
244
337
  }
245
338
 
246
339
  session.emulator.scrollToBottom()
247
- this.emitRenderIfChanged(session)
340
+ this.scheduleRender(session)
248
341
  }
249
342
 
250
343
  private applyScrollIntent(session: SessionHandle, intent: ScrollIntent | undefined): void {
@@ -262,7 +355,12 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
262
355
  session.emulator.scrollToLine(Math.max(0, intent.absoluteLine))
263
356
  }
264
357
 
265
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
358
+ resizeAll(
359
+ cols: number,
360
+ rows: number,
361
+ intents?: Map<string, ScrollIntent>,
362
+ options?: { sync?: boolean }
363
+ ): void {
266
364
  const safeCols = Math.max(20, cols)
267
365
  const safeRows = Math.max(8, rows)
268
366
 
@@ -270,11 +368,21 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
270
368
  session.pty.resize(safeCols, safeRows)
271
369
  session.emulator.resize(safeCols, safeRows)
272
370
  this.applyScrollIntent(session, intents?.get(session.tabId))
273
- this.emitRenderIfChanged(session)
371
+ if (options?.sync) {
372
+ this.flushRenderNow(session)
373
+ } else {
374
+ this.scheduleRender(session)
375
+ }
274
376
  }
275
377
  }
276
378
 
277
- resizeSession(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
379
+ resizeSession(
380
+ tabId: string,
381
+ cols: number,
382
+ rows: number,
383
+ intent?: ScrollIntent,
384
+ options?: { sync?: boolean }
385
+ ): void {
278
386
  const session = this.sessions.get(tabId)
279
387
  if (!session) {
280
388
  return
@@ -284,7 +392,11 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
284
392
  session.pty.resize(safeCols, safeRows)
285
393
  session.emulator.resize(safeCols, safeRows)
286
394
  this.applyScrollIntent(session, intent)
287
- this.emitRenderIfChanged(session)
395
+ if (options?.sync) {
396
+ this.flushRenderNow(session)
397
+ } else {
398
+ this.scheduleRender(session)
399
+ }
288
400
  }
289
401
 
290
402
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void {
@@ -293,7 +405,7 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
293
405
  return
294
406
  }
295
407
  this.applyScrollIntent(session, intent)
296
- this.emitRenderIfChanged(session)
408
+ this.scheduleRender(session)
297
409
  }
298
410
 
299
411
  disposeSession(tabId: string): void {
@@ -302,12 +414,16 @@ export class PtyManager extends EventEmitter<PtyManagerEvents> {
302
414
  return
303
415
  }
304
416
 
417
+ this.clearTimers(tabId)
305
418
  this.sessions.delete(tabId)
306
419
  session.pty.kill()
307
420
  session.emulator.dispose()
308
421
  }
309
422
 
310
423
  disposeAll(): void {
424
+ for (const tabId of this.sessions.keys()) {
425
+ this.clearTimers(tabId)
426
+ }
311
427
  for (const session of this.sessions.values()) {
312
428
  session.pty.kill()
313
429
  session.emulator.dispose()
@@ -0,0 +1,44 @@
1
+ import {
2
+ getTerminalManagerSocketPath,
3
+ removeTerminalManagerSocketIfExists,
4
+ } from './daemon/runtime-paths'
5
+ import {
6
+ findIpcDaemonPid,
7
+ findTerminalManagerPid,
8
+ killProcess,
9
+ spawnDetachedTerminalManager,
10
+ } from './platform/daemon-control'
11
+
12
+ export async function runRestartTerminalManager(): Promise<number> {
13
+ const socketPath = getTerminalManagerSocketPath()
14
+
15
+ const daemonPid = await findIpcDaemonPid()
16
+ if (daemonPid !== null) {
17
+ process.stdout.write(`Stopping IPC daemon (pid ${daemonPid})...\n`)
18
+ await killProcess(daemonPid)
19
+ }
20
+
21
+ const managerPid = await findTerminalManagerPid()
22
+ if (managerPid !== null) {
23
+ process.stdout.write(
24
+ `WARNING: killing terminal-manager (pid ${managerPid}) will kill live sessions.\n`
25
+ )
26
+ await killProcess(managerPid)
27
+ process.stdout.write('Terminal-manager stopped.\n')
28
+ } else {
29
+ process.stdout.write('No running terminal-manager found.\n')
30
+ }
31
+
32
+ removeTerminalManagerSocketIfExists()
33
+
34
+ process.stdout.write('Starting terminal-manager...\n')
35
+ const ok = await spawnDetachedTerminalManager()
36
+
37
+ if (ok) {
38
+ process.stdout.write(`Terminal-manager started on ${socketPath}.\n`)
39
+ return 0
40
+ }
41
+
42
+ process.stderr.write('Failed to start terminal-manager.\n')
43
+ return 1
44
+ }
@@ -12,12 +12,16 @@ import {
12
12
  toTerminalContentSize,
13
13
  } from '../state/layout-resize'
14
14
 
15
+ const SESSION_IDLE_TIMEOUT_MS = 2_000
16
+
15
17
  export class LocalSessionBackend
16
18
  extends EventEmitter<SessionBackendEvents>
17
19
  implements SessionBackend
18
20
  {
19
21
  private readonly sessionManager = new SessionManager()
20
22
  private currentSessionId: string | null = null
23
+ private readonly sessionIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
24
+ private readonly sessionBusy = new Map<string, boolean>()
21
25
 
22
26
  constructor() {
23
27
  super()
@@ -25,6 +29,7 @@ export class LocalSessionBackend
25
29
  if (sessionId === this.currentSessionId) {
26
30
  this.emit('render', tabId, viewport, terminalModes)
27
31
  }
32
+ this.markSessionBusy(sessionId)
28
33
  })
29
34
  this.sessionManager.on('exit', (sessionId, tabId, exitCode) => {
30
35
  if (sessionId === this.currentSessionId) {
@@ -38,6 +43,23 @@ export class LocalSessionBackend
38
43
  })
39
44
  }
40
45
 
46
+ private markSessionBusy(sessionId: string): void {
47
+ if (this.sessionBusy.get(sessionId) !== true) {
48
+ this.sessionBusy.set(sessionId, true)
49
+ this.emit('sessionActivity', sessionId, true)
50
+ }
51
+ const existing = this.sessionIdleTimers.get(sessionId)
52
+ if (existing) clearTimeout(existing)
53
+ const timer = setTimeout(() => {
54
+ this.sessionIdleTimers.delete(sessionId)
55
+ if (this.sessionBusy.get(sessionId) === true) {
56
+ this.sessionBusy.set(sessionId, false)
57
+ this.emit('sessionActivity', sessionId, false)
58
+ }
59
+ }, SESSION_IDLE_TIMEOUT_MS)
60
+ this.sessionIdleTimers.set(sessionId, timer)
61
+ }
62
+
41
63
  async attach(options: {
42
64
  sessionId: string
43
65
  cols: number
@@ -129,18 +151,29 @@ export class LocalSessionBackend
129
151
  this.sessionManager.setActiveTab(this.currentSessionId, tabId)
130
152
  }
131
153
 
132
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
154
+ resizeAll(
155
+ cols: number,
156
+ rows: number,
157
+ intents?: Map<string, ScrollIntent>,
158
+ options?: { sync?: boolean }
159
+ ): void {
133
160
  if (!this.currentSessionId) {
134
161
  return
135
162
  }
136
- this.sessionManager.resize(this.currentSessionId, cols, rows, intents)
163
+ this.sessionManager.resize(this.currentSessionId, cols, rows, intents, options)
137
164
  }
138
165
 
139
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
166
+ resizeTab(
167
+ tabId: string,
168
+ cols: number,
169
+ rows: number,
170
+ intent?: ScrollIntent,
171
+ options?: { sync?: boolean }
172
+ ): void {
140
173
  if (!this.currentSessionId) {
141
174
  return
142
175
  }
143
- this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent)
176
+ this.sessionManager.resizeTab(this.currentSessionId, tabId, cols, rows, intent, options)
144
177
  }
145
178
 
146
179
  disposeSession(tabId: string): void {
@@ -166,6 +199,11 @@ export class LocalSessionBackend
166
199
  this.sessionManager.disposeSession(this.currentSessionId)
167
200
  }
168
201
  }
202
+ for (const timer of this.sessionIdleTimers.values()) {
203
+ clearTimeout(timer)
204
+ }
205
+ this.sessionIdleTimers.clear()
206
+ this.sessionBusy.clear()
169
207
  this.currentSessionId = null
170
208
  }
171
209
  }
@@ -398,7 +398,12 @@ export class RemoteSessionBackend
398
398
  }).catch((error) => this.reportCommandError('setActiveTab', error))
399
399
  }
400
400
 
401
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void {
401
+ resizeAll(
402
+ cols: number,
403
+ rows: number,
404
+ intents?: Map<string, ScrollIntent>,
405
+ _options?: { sync?: boolean }
406
+ ): void {
402
407
  if (!this.attached) {
403
408
  logDebug('backend.remote.skipResizeBeforeAttach', { cols, rows })
404
409
  return
@@ -412,7 +417,13 @@ export class RemoteSessionBackend
412
417
  }).catch((error) => this.reportCommandError('resizeClient', error))
413
418
  }
414
419
 
415
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void {
420
+ resizeTab(
421
+ tabId: string,
422
+ cols: number,
423
+ rows: number,
424
+ intent?: ScrollIntent,
425
+ _options?: { sync?: boolean }
426
+ ): void {
416
427
  if (!this.attached) {
417
428
  return
418
429
  }
@@ -12,6 +12,7 @@ export type SessionBackendEvents = {
12
12
  render: [tabId: string, viewport: TerminalSnapshot, terminalModes: TerminalModeState]
13
13
  exit: [tabId: string, exitCode: number]
14
14
  error: [tabId: string, message: string]
15
+ sessionActivity: [sessionId: string, busy: boolean]
15
16
  }
16
17
 
17
18
  export interface BackendAttachResult {
@@ -41,8 +42,19 @@ export interface SessionBackend extends EventEmitter<SessionBackendEvents> {
41
42
  scrollViewportToBottom(tabId: string): void
42
43
  reapplyScrollIntent(tabId: string, intent: ScrollIntent): void
43
44
  setActiveTab(tabId: string | null): void
44
- resizeAll(cols: number, rows: number, intents?: Map<string, ScrollIntent>): void
45
- resizeTab(tabId: string, cols: number, rows: number, intent?: ScrollIntent): void
45
+ resizeAll(
46
+ cols: number,
47
+ rows: number,
48
+ intents?: Map<string, ScrollIntent>,
49
+ options?: { sync?: boolean }
50
+ ): void
51
+ resizeTab(
52
+ tabId: string,
53
+ cols: number,
54
+ rows: number,
55
+ intent?: ScrollIntent,
56
+ options?: { sync?: boolean }
57
+ ): void
46
58
  disposeSession(tabId: string): void
47
59
  disposeAll(): void
48
60
  destroy(keepSessions?: boolean): Promise<void> | void
@@ -1,8 +1,11 @@
1
+ import type { SideEffect } from '../input/modes/types'
1
2
  import type { AppAction } from './types'
2
3
 
3
4
  type DispatchFn = (action: AppAction) => void
5
+ type SideEffectFn = (effect: SideEffect) => void
4
6
 
5
7
  let activeDispatch: DispatchFn | null = null
8
+ let activeSideEffect: SideEffectFn | null = null
6
9
 
7
10
  export function setActiveDispatch(dispatch: DispatchFn | null): void {
8
11
  activeDispatch = dispatch
@@ -11,3 +14,11 @@ export function setActiveDispatch(dispatch: DispatchFn | null): void {
11
14
  export function dispatchGlobal(action: AppAction): void {
12
15
  activeDispatch?.(action)
13
16
  }
17
+
18
+ export function setActiveSideEffectRunner(runner: SideEffectFn | null): void {
19
+ activeSideEffect = runner
20
+ }
21
+
22
+ export function runSideEffectGlobal(effect: SideEffect): void {
23
+ activeSideEffect?.(effect)
24
+ }
@@ -64,6 +64,8 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
64
64
  const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
65
65
  const maxIndex = filteredNew.length
66
66
  const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
67
+ const nextBusy = { ...state.sessionsBusy }
68
+ delete nextBusy[action.sessionId]
67
69
  return {
68
70
  ...state,
69
71
  activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
@@ -77,9 +79,35 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
77
79
  type: 'session-picker',
78
80
  },
79
81
  sessions: newSessions,
82
+ sessionsBusy: nextBusy,
80
83
  tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
81
84
  }
82
85
  }
86
+ case 'reorder-sessions': {
87
+ const byId = new Map(state.sessions.map((s) => [s.id, s]))
88
+ const ordered: typeof state.sessions = []
89
+ let idx = 0
90
+ for (const id of action.orderedIds) {
91
+ const s = byId.get(id)
92
+ if (s) {
93
+ ordered.push({ ...s, order: idx })
94
+ byId.delete(id)
95
+ }
96
+ idx++
97
+ }
98
+ let nextOrder = ordered.length
99
+ for (const s of byId.values()) {
100
+ ordered.push({ ...s, order: nextOrder++ })
101
+ }
102
+ return { ...state, sessions: ordered }
103
+ }
104
+ case 'set-session-busy': {
105
+ if ((state.sessionsBusy[action.sessionId] ?? false) === action.busy) return state
106
+ return {
107
+ ...state,
108
+ sessionsBusy: { ...state.sessionsBusy, [action.sessionId]: action.busy },
109
+ }
110
+ }
83
111
  default:
84
112
  return null
85
113
  }
@@ -23,6 +23,14 @@ export function reduceUIState(state: AppState, action: AppAction): AppState | nu
23
23
  return state
24
24
  }
25
25
  return { ...state, pendingChords: action.chords }
26
+ case 'toggle-session-bar':
27
+ return {
28
+ ...state,
29
+ sessionBar: { ...state.sessionBar, visible: !state.sessionBar.visible },
30
+ }
31
+ case 'set-session-bar-position':
32
+ if (state.sessionBar.position === action.position) return state
33
+ return { ...state, sessionBar: { ...state.sessionBar, position: action.position } }
26
34
  default:
27
35
  return null
28
36
  }
@@ -46,7 +46,7 @@ export function loadSessionCatalog(): SessionRecord[] {
46
46
  const { file, issue } = readCatalogFile()
47
47
  if (file) {
48
48
  logDebug('sessions.catalog.load', { sessionCount: file.sessions.length })
49
- return file.sessions
49
+ return normalizeOrder(file.sessions)
50
50
  }
51
51
 
52
52
  if (issue) {
@@ -95,3 +95,24 @@ export function saveSessionCatalog(sessions: SessionRecord[]): void {
95
95
  export function getSessionCatalogPath(): string {
96
96
  return SESSIONS_PATH
97
97
  }
98
+
99
+ /**
100
+ * Assign a stable `order` to every session. Records with an existing numeric
101
+ * `order` keep their slot (sorted ascending); the rest are appended by
102
+ * createdAt ascending so older sessions come first.
103
+ */
104
+ function normalizeOrder(sessions: SessionRecord[]): SessionRecord[] {
105
+ const withOrder: SessionRecord[] = []
106
+ const withoutOrder: SessionRecord[] = []
107
+ for (const s of sessions) {
108
+ if (typeof s.order === 'number' && Number.isFinite(s.order)) {
109
+ withOrder.push(s)
110
+ } else {
111
+ withoutOrder.push(s)
112
+ }
113
+ }
114
+ withOrder.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
115
+ withoutOrder.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
116
+ const merged = [...withOrder, ...withoutOrder]
117
+ return merged.map((s, i) => (s.order === i ? s : { ...s, order: i }))
118
+ }
@@ -1,4 +1,4 @@
1
- import type { AppAction, AppState, SessionRecord, SnippetRecord } from './types'
1
+ import type { AppAction, AppState, SessionBarPosition, SessionRecord, SnippetRecord } from './types'
2
2
 
3
3
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
4
4
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
@@ -17,6 +17,8 @@ const DEFAULT_TERMINAL_ROWS = 24
17
17
  export interface InitialStateOverrides {
18
18
  gitPanelVisible?: boolean
19
19
  gitPanelRatio?: number
20
+ sessionBarVisible?: boolean
21
+ sessionBarPosition?: SessionBarPosition
20
22
  }
21
23
 
22
24
  export function createInitialState(
@@ -42,7 +44,12 @@ export function createInitialState(
42
44
  ? { editBuffer: null, selectedIndex: 0, sessionTargetId: null, type: 'session-picker' }
43
45
  : emptyModal(),
44
46
  pendingChords: null,
47
+ sessionBar: {
48
+ position: overrides.sessionBarPosition ?? 'top',
49
+ visible: overrides.sessionBarVisible ?? true,
50
+ },
45
51
  sessions,
52
+ sessionsBusy: {},
46
53
  sidebar: {
47
54
  gitPanelRatio: overrides.gitPanelRatio ?? 0.5,
48
55
  gitPanelVisible: overrides.gitPanelVisible ?? true,
@@ -105,9 +105,17 @@ export interface SessionRecord {
105
105
  createdAt: string
106
106
  updatedAt: string
107
107
  lastOpenedAt: string
108
+ order?: number
108
109
  workspaceSnapshot?: WorkspaceSnapshotV1
109
110
  }
110
111
 
112
+ export type SessionBarPosition = 'top' | 'bottom'
113
+
114
+ export interface SessionBarState {
115
+ visible: boolean
116
+ position: SessionBarPosition
117
+ }
118
+
111
119
  export interface TabSession {
112
120
  id: string
113
121
  assistant: AssistantId
@@ -287,6 +295,8 @@ export interface AppState {
287
295
  tabGroupMap: Record<string, string>
288
296
  sessions: SessionRecord[]
289
297
  currentSessionId: string | null
298
+ sessionsBusy: Record<string, boolean>
299
+ sessionBar: SessionBarState
290
300
  snippets: SnippetRecord[]
291
301
  focusMode: FocusMode
292
302
  sidebar: SidebarState
@@ -332,6 +342,8 @@ export type SessionAction =
332
342
  | { type: 'create-session-record'; session: SessionRecord }
333
343
  | { type: 'rename-session-record'; sessionId: string; name: string }
334
344
  | { type: 'delete-session-record'; sessionId: string }
345
+ | { type: 'reorder-sessions'; orderedIds: string[] }
346
+ | { type: 'set-session-busy'; sessionId: string; busy: boolean }
335
347
 
336
348
  // -- Tab actions --
337
349
  export type TabAction =
@@ -398,6 +410,8 @@ export type UIAction =
398
410
  | { type: 'toggle-git-panel' }
399
411
  | { type: 'resize-git-panel'; delta: number }
400
412
  | { type: 'set-pending-chords'; chords: string[] | null }
413
+ | { type: 'toggle-session-bar' }
414
+ | { type: 'set-session-bar-position'; position: SessionBarPosition }
401
415
 
402
416
  // -- Git panel actions --
403
417
  export interface GitRefreshPayload {
@@ -151,6 +151,8 @@ export function isSessionRecord(value: unknown): value is SessionRecord {
151
151
  isString(value.createdAt) &&
152
152
  isString(value.updatedAt) &&
153
153
  isString(value.lastOpenedAt) &&
154
+ (value.order === undefined ||
155
+ (typeof value.order === 'number' && Number.isFinite(value.order))) &&
154
156
  (value.workspaceSnapshot === undefined || isWorkspaceSnapshotV1(value.workspaceSnapshot))
155
157
  )
156
158
  }
@@ -26,6 +26,8 @@ export function saveCurrentWorkspace(state: AppState): void {
26
26
  customCommands: state.customCommands,
27
27
  gitPanelRatio: state.sidebar.gitPanelRatio,
28
28
  gitPanelVisible: state.sidebar.gitPanelVisible,
29
+ sessionBarPosition: state.sessionBar.position,
30
+ sessionBarVisible: state.sessionBar.visible,
29
31
  })
30
32
  saveSessionCatalog(
31
33
  buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)