@brimveyn/aimux 1.3.1 → 1.4.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.
- package/README.md +3 -0
- package/package.json +2 -2
- package/src/app-runtime/backend-runtime-events.ts +6 -0
- package/src/app-runtime/side-effects.ts +18 -0
- package/src/app.tsx +24 -7
- package/src/config.ts +21 -1
- package/src/input/modes/types.ts +1 -0
- package/src/session-backend/local-session-backend.ts +27 -0
- package/src/session-backend/types.ts +1 -0
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +14 -0
- package/src/state/validation.ts +2 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/ui/components/session-bar.tsx +208 -0
- package/src/ui/components/tab-item.tsx +3 -15
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/root.tsx +4 -0
- package/src/ui/session-ordering.ts +34 -0
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ A terminal multiplexer for AI CLIs. Manage multiple AI assistant sessions (Claud
|
|
|
11
11
|
## Features
|
|
12
12
|
|
|
13
13
|
- **Multi-tab sessions** — Run Claude, Codex, and OpenCode in parallel with instant tab switching
|
|
14
|
+
- **Session bar** — Numbered session chips at the top (or bottom) of the screen. Click to switch, drag to reorder, busy spinner for non-focused sessions with live PTY output. Toggle with `<leader>b`; jump with `<leader>1..9`. Position persists via `aimux.config.ts` or `aimux.json`.
|
|
14
15
|
- **Split panes** — Split vertically (`|`) or horizontally (`-`) to view multiple assistants at once
|
|
15
16
|
- **Draggable separators** — Resize split panes by dragging with the mouse
|
|
16
17
|
- **Click-to-focus** — Click any pane or sidebar tab to focus it instantly
|
|
@@ -134,6 +135,8 @@ Press `?` in navigation mode for the full, live keybinding list (reflects your c
|
|
|
134
135
|
| `Ctrl+S` | Snippet picker |
|
|
135
136
|
| `Ctrl+T` | Theme picker |
|
|
136
137
|
| `G` | Toggle git panel |
|
|
138
|
+
| `<leader>b` | Toggle session bar |
|
|
139
|
+
| `<leader>1..9` | Switch to session N |
|
|
137
140
|
| `?` | Show help |
|
|
138
141
|
| `Ctrl+C` | Quit |
|
|
139
142
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
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",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"format:check": "oxfmt --check ."
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@brimveyn/aimux-config": "0.
|
|
60
|
+
"@brimveyn/aimux-config": "0.3.0",
|
|
61
61
|
"@opentui/core": "^0.1.90",
|
|
62
62
|
"@opentui/react": "^0.1.90",
|
|
63
63
|
"@xterm/headless": "^6.0.0",
|
|
@@ -79,15 +79,21 @@ export function bindBackendRuntimeEvents({
|
|
|
79
79
|
dispatch({ message, tabId, type: 'set-tab-error' })
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
const handleSessionActivity = (sessionId: string, busy: boolean) => {
|
|
83
|
+
dispatch({ busy, sessionId, type: 'set-session-busy' })
|
|
84
|
+
}
|
|
85
|
+
|
|
82
86
|
backend.on('render', handleRender)
|
|
83
87
|
backend.on('exit', handleExit)
|
|
84
88
|
backend.on('error', handleError)
|
|
89
|
+
backend.on('sessionActivity', handleSessionActivity)
|
|
85
90
|
|
|
86
91
|
return () => {
|
|
87
92
|
timeouts.clearAllTimers()
|
|
88
93
|
backend.off('render', handleRender)
|
|
89
94
|
backend.off('exit', handleExit)
|
|
90
95
|
backend.off('error', handleError)
|
|
96
|
+
backend.off('sessionActivity', handleSessionActivity)
|
|
91
97
|
void backend.destroy(true)
|
|
92
98
|
}
|
|
93
99
|
}
|
|
@@ -488,11 +488,29 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
|
|
|
488
488
|
handleConfirmUpdateSelection(ctx)
|
|
489
489
|
return
|
|
490
490
|
}
|
|
491
|
+
case 'switch-session-by-index': {
|
|
492
|
+
handleSwitchSessionByIndex(ctx, effect.index)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
491
495
|
default:
|
|
492
496
|
effect satisfies never
|
|
493
497
|
}
|
|
494
498
|
}
|
|
495
499
|
|
|
500
|
+
function handleSwitchSessionByIndex(ctx: SideEffectContext, index: number): void {
|
|
501
|
+
const { backend, dispatch, state } = ctx
|
|
502
|
+
const ordered = state.sessions
|
|
503
|
+
.slice()
|
|
504
|
+
.sort((a, b) => (a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER))
|
|
505
|
+
const target = ordered[index - 1]
|
|
506
|
+
if (!target) {
|
|
507
|
+
logInputDebug('app.sessionBar.switchOutOfRange', { index, total: ordered.length })
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
if (target.id === state.currentSessionId) return
|
|
511
|
+
handleSwitchSessionEffect(state, backend, dispatch, target)
|
|
512
|
+
}
|
|
513
|
+
|
|
496
514
|
function handleConfirmUpdateSelection(ctx: SideEffectContext): void {
|
|
497
515
|
const { state } = ctx
|
|
498
516
|
if (state.modal.type !== 'update-available') {
|
package/src/app.tsx
CHANGED
|
@@ -31,7 +31,7 @@ import { getHandler, transitionTo } from './input/modes/registry'
|
|
|
31
31
|
import { type TerminalContentOrigin } from './input/raw-input-handler'
|
|
32
32
|
import { getProfileName } from './profile-paths'
|
|
33
33
|
import { appStore } from './state/app-store'
|
|
34
|
-
import { setActiveDispatch } from './state/dispatch-ref'
|
|
34
|
+
import { setActiveDispatch, setActiveSideEffectRunner } from './state/dispatch-ref'
|
|
35
35
|
import { loadSessionCatalog } from './state/session-catalog'
|
|
36
36
|
import { loadSnippetCatalog } from './state/snippet-catalog'
|
|
37
37
|
import { appReducer, createInitialState } from './state/store'
|
|
@@ -69,11 +69,22 @@ export function App({
|
|
|
69
69
|
return config.themeId ?? 'aimux'
|
|
70
70
|
})
|
|
71
71
|
const [state, dispatch] = useReducer(appReducer, undefined, () => {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
const json = loadConfig()
|
|
73
|
+
const sessionBarVisible = resolvedConfig.sessionBar?.visible ?? json.sessionBarVisible ?? true
|
|
74
|
+
const sessionBarPosition =
|
|
75
|
+
resolvedConfig.sessionBar?.position ?? json.sessionBarPosition ?? 'top'
|
|
76
|
+
return createInitialState(
|
|
77
|
+
json.customCommands,
|
|
78
|
+
loadSessionCatalog(),
|
|
79
|
+
loadSnippetCatalog(),
|
|
80
|
+
true,
|
|
81
|
+
{
|
|
82
|
+
gitPanelRatio: json.gitPanelRatio,
|
|
83
|
+
gitPanelVisible: json.gitPanelVisible,
|
|
84
|
+
sessionBarPosition,
|
|
85
|
+
sessionBarVisible,
|
|
86
|
+
}
|
|
87
|
+
)
|
|
77
88
|
})
|
|
78
89
|
|
|
79
90
|
useLayoutEffect(() => {
|
|
@@ -82,7 +93,10 @@ export function App({
|
|
|
82
93
|
|
|
83
94
|
useLayoutEffect(() => {
|
|
84
95
|
setActiveDispatch(dispatch)
|
|
85
|
-
return () =>
|
|
96
|
+
return () => {
|
|
97
|
+
setActiveDispatch(null)
|
|
98
|
+
setActiveSideEffectRunner(null)
|
|
99
|
+
}
|
|
86
100
|
}, [dispatch])
|
|
87
101
|
|
|
88
102
|
useEffect(() => {
|
|
@@ -251,6 +265,9 @@ export function App({
|
|
|
251
265
|
|
|
252
266
|
// Keep the ref pointing at the latest closure so stable callbacks can invoke it
|
|
253
267
|
processKeyResultRef.current = processKeyResult
|
|
268
|
+
// Expose the side-effect runner so non-keyboard call sites (mouse, IPC) can
|
|
269
|
+
// invoke the same effect pipeline with the current context.
|
|
270
|
+
setActiveSideEffectRunner((effect) => executeSideEffect(effect, sideEffectCtx))
|
|
254
271
|
|
|
255
272
|
useLayoutEffect(() => {
|
|
256
273
|
for (const handler of keymapHandlers) {
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
|
|
3
|
-
import type { WorkspaceSnapshotV1 } from './state/types'
|
|
3
|
+
import type { SessionBarPosition, WorkspaceSnapshotV1 } from './state/types'
|
|
4
4
|
|
|
5
5
|
import { logDebug } from './debug/input-log'
|
|
6
6
|
import { getProfileConfigDir } from './profile-paths'
|
|
@@ -15,6 +15,8 @@ export interface AimuxConfig {
|
|
|
15
15
|
themeId?: ThemeId
|
|
16
16
|
gitPanelVisible?: boolean
|
|
17
17
|
gitPanelRatio?: number
|
|
18
|
+
sessionBarVisible?: boolean
|
|
19
|
+
sessionBarPosition?: SessionBarPosition
|
|
18
20
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
19
21
|
skippedUpdateVersion?: string
|
|
20
22
|
}
|
|
@@ -58,6 +60,8 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
58
60
|
themeId?: unknown
|
|
59
61
|
gitPanelVisible?: unknown
|
|
60
62
|
gitPanelRatio?: unknown
|
|
63
|
+
sessionBarVisible?: unknown
|
|
64
|
+
sessionBarPosition?: unknown
|
|
61
65
|
workspaceSnapshot?: unknown
|
|
62
66
|
skippedUpdateVersion?: unknown
|
|
63
67
|
}
|
|
@@ -93,6 +97,20 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
93
97
|
issues.push('ignored invalid gitPanelRatio')
|
|
94
98
|
}
|
|
95
99
|
|
|
100
|
+
const validSessionBarVisible =
|
|
101
|
+
typeof parsed.sessionBarVisible === 'boolean' ? parsed.sessionBarVisible : undefined
|
|
102
|
+
if (parsed.sessionBarVisible !== undefined && validSessionBarVisible === undefined) {
|
|
103
|
+
issues.push('ignored invalid sessionBarVisible')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const validSessionBarPosition =
|
|
107
|
+
parsed.sessionBarPosition === 'top' || parsed.sessionBarPosition === 'bottom'
|
|
108
|
+
? parsed.sessionBarPosition
|
|
109
|
+
: undefined
|
|
110
|
+
if (parsed.sessionBarPosition !== undefined && validSessionBarPosition === undefined) {
|
|
111
|
+
issues.push('ignored invalid sessionBarPosition')
|
|
112
|
+
}
|
|
113
|
+
|
|
96
114
|
if (
|
|
97
115
|
parsed.workspaceSnapshot !== undefined &&
|
|
98
116
|
!isWorkspaceSnapshotV1(parsed.workspaceSnapshot)
|
|
@@ -117,6 +135,8 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
117
135
|
customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
|
|
118
136
|
gitPanelRatio: validGitPanelRatio,
|
|
119
137
|
gitPanelVisible: validGitPanelVisible,
|
|
138
|
+
sessionBarPosition: validSessionBarPosition,
|
|
139
|
+
sessionBarVisible: validSessionBarVisible,
|
|
120
140
|
skippedUpdateVersion: validSkippedUpdateVersion,
|
|
121
141
|
themeId: isThemeId(parsed.themeId) ? parsed.themeId : undefined,
|
|
122
142
|
version: 2,
|
package/src/input/modes/types.ts
CHANGED
|
@@ -53,6 +53,7 @@ export type SideEffect =
|
|
|
53
53
|
| { type: 'git-commit'; title: string; body: string }
|
|
54
54
|
| { type: 'git-push' }
|
|
55
55
|
| { type: 'confirm-update-selection' }
|
|
56
|
+
| { type: 'switch-session-by-index'; index: number }
|
|
56
57
|
|
|
57
58
|
export interface KeyResult {
|
|
58
59
|
actions: AppAction[]
|
|
@@ -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
|
|
@@ -166,6 +188,11 @@ export class LocalSessionBackend
|
|
|
166
188
|
this.sessionManager.disposeSession(this.currentSessionId)
|
|
167
189
|
}
|
|
168
190
|
}
|
|
191
|
+
for (const timer of this.sessionIdleTimers.values()) {
|
|
192
|
+
clearTimeout(timer)
|
|
193
|
+
}
|
|
194
|
+
this.sessionIdleTimers.clear()
|
|
195
|
+
this.sessionBusy.clear()
|
|
169
196
|
this.currentSessionId = null
|
|
170
197
|
}
|
|
171
198
|
}
|
|
@@ -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 {
|
|
@@ -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
|
+
}
|
package/src/state/store.ts
CHANGED
|
@@ -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,
|
package/src/state/types.ts
CHANGED
|
@@ -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 {
|
package/src/state/validation.ts
CHANGED
|
@@ -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)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { SessionRecord } from '../../state/types'
|
|
6
|
+
|
|
7
|
+
import { useAppStore } from '../../state/app-store'
|
|
8
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
9
|
+
import { useBusySpinner } from '../hooks/use-busy-spinner'
|
|
10
|
+
import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
|
|
11
|
+
import { theme } from '../theme'
|
|
12
|
+
|
|
13
|
+
export function SessionBar() {
|
|
14
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
15
|
+
const currentId = useAppStore((s) => s.currentSessionId)
|
|
16
|
+
const bar = useAppStore((s) => s.sessionBar)
|
|
17
|
+
const busyMap = useAppStore((s) => s.sessionsBusy)
|
|
18
|
+
|
|
19
|
+
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
20
|
+
const [dragOrder, setDragOrder] = useState<string[] | null>(null)
|
|
21
|
+
// Hysteresis: the id of the chip we most recently swapped with. While the
|
|
22
|
+
// cursor remains over that chip we refuse to swap back (prevents oscillation
|
|
23
|
+
// when a long chip's new bounds still cover the cursor after a swap).
|
|
24
|
+
const lastSwapWithRef = useRef<string | null>(null)
|
|
25
|
+
// Live bounds of each chip after render, keyed by session id.
|
|
26
|
+
const chipRefs = useRef(new Map<string, BoxRenderable>())
|
|
27
|
+
|
|
28
|
+
const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
|
|
29
|
+
if (!bar.visible || ordered.length === 0) return null
|
|
30
|
+
|
|
31
|
+
const visibleSessions =
|
|
32
|
+
dragOrder !== null
|
|
33
|
+
? dragOrder
|
|
34
|
+
.map((id) => ordered.find((s) => s.id === id))
|
|
35
|
+
.filter((s): s is SessionRecord => !!s)
|
|
36
|
+
: ordered
|
|
37
|
+
|
|
38
|
+
const baselineOrder = ordered.map((s) => s.id)
|
|
39
|
+
|
|
40
|
+
function setChipRef(id: string, ref: BoxRenderable | null): void {
|
|
41
|
+
if (ref) chipRefs.current.set(id, ref)
|
|
42
|
+
else chipRefs.current.delete(id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findChipAtX(x: number): string | null {
|
|
46
|
+
for (const [id, ref] of chipRefs.current) {
|
|
47
|
+
if (x >= ref.x && x < ref.x + ref.width) return id
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleMouseDown = (id: string) => {
|
|
53
|
+
setDraggingId(id)
|
|
54
|
+
setDragOrder(baselineOrder)
|
|
55
|
+
lastSwapWithRef.current = null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handleMouseDrag = (event: OtuiMouseEvent) => {
|
|
59
|
+
if (!draggingId) return
|
|
60
|
+
const hit = findChipAtX(event.x)
|
|
61
|
+
if (hit === null) {
|
|
62
|
+
// Cursor left the bar entirely — allow the next hit to re-trigger a swap.
|
|
63
|
+
lastSwapWithRef.current = null
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (hit === draggingId) {
|
|
67
|
+
// Over the dragged chip itself — reset hysteresis so re-entering a
|
|
68
|
+
// neighbour can swap again.
|
|
69
|
+
lastSwapWithRef.current = null
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (hit === lastSwapWithRef.current) return
|
|
73
|
+
setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
|
|
74
|
+
lastSwapWithRef.current = hit
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const commitDrop = () => {
|
|
78
|
+
const source = draggingId
|
|
79
|
+
const finalOrder = dragOrder
|
|
80
|
+
setDraggingId(null)
|
|
81
|
+
setDragOrder(null)
|
|
82
|
+
lastSwapWithRef.current = null
|
|
83
|
+
|
|
84
|
+
if (!source || !finalOrder) return
|
|
85
|
+
|
|
86
|
+
const changed = !arraysEqual(finalOrder, baselineOrder)
|
|
87
|
+
if (changed) {
|
|
88
|
+
dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Drag did not change anything → treat as click, switch to that session.
|
|
93
|
+
const idx = baselineOrder.indexOf(source)
|
|
94
|
+
if (idx >= 0) {
|
|
95
|
+
runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cancelDrag = () => {
|
|
100
|
+
setDraggingId(null)
|
|
101
|
+
setDragOrder(null)
|
|
102
|
+
lastSwapWithRef.current = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<box
|
|
107
|
+
width="100%"
|
|
108
|
+
flexDirection="row"
|
|
109
|
+
paddingLeft={1}
|
|
110
|
+
paddingRight={1}
|
|
111
|
+
backgroundColor={theme.panelMuted}
|
|
112
|
+
>
|
|
113
|
+
{visibleSessions.map((session) => {
|
|
114
|
+
const displayIndex = baselineOrder.indexOf(session.id) + 1
|
|
115
|
+
return (
|
|
116
|
+
<SessionChip
|
|
117
|
+
key={session.id}
|
|
118
|
+
session={session}
|
|
119
|
+
index={displayIndex}
|
|
120
|
+
active={session.id === currentId}
|
|
121
|
+
busy={busyMap[session.id] ?? false}
|
|
122
|
+
dragging={draggingId === session.id}
|
|
123
|
+
onRef={(r) => setChipRef(session.id, r)}
|
|
124
|
+
onMouseDown={() => handleMouseDown(session.id)}
|
|
125
|
+
onMouseDrag={handleMouseDrag}
|
|
126
|
+
onMouseUp={commitDrop}
|
|
127
|
+
onMouseDragEnd={cancelDrag}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
})}
|
|
131
|
+
</box>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
136
|
+
if (a.length !== b.length) return false
|
|
137
|
+
for (let i = 0; i < a.length; i++) {
|
|
138
|
+
if (a[i] !== b[i]) return false
|
|
139
|
+
}
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface SessionChipProps {
|
|
144
|
+
session: SessionRecord
|
|
145
|
+
index: number
|
|
146
|
+
active: boolean
|
|
147
|
+
busy: boolean
|
|
148
|
+
dragging: boolean
|
|
149
|
+
onRef: (ref: BoxRenderable | null) => void
|
|
150
|
+
onMouseDown: (event: OtuiMouseEvent) => void
|
|
151
|
+
onMouseDrag: (event: OtuiMouseEvent) => void
|
|
152
|
+
onMouseUp: (event: OtuiMouseEvent) => void
|
|
153
|
+
onMouseDragEnd: (event: OtuiMouseEvent) => void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function SessionChip({
|
|
157
|
+
active,
|
|
158
|
+
busy,
|
|
159
|
+
dragging,
|
|
160
|
+
index,
|
|
161
|
+
onMouseDown,
|
|
162
|
+
onMouseDrag,
|
|
163
|
+
onMouseDragEnd,
|
|
164
|
+
onMouseUp,
|
|
165
|
+
onRef,
|
|
166
|
+
session,
|
|
167
|
+
}: SessionChipProps) {
|
|
168
|
+
const showSpinner = busy && !active
|
|
169
|
+
const spinner = useBusySpinner(showSpinner)
|
|
170
|
+
const indicator = showSpinner ? spinner : '●'
|
|
171
|
+
const indicatorColor = active || showSpinner ? theme.accent : theme.success
|
|
172
|
+
const labelColor = active ? theme.text : theme.textMuted
|
|
173
|
+
const bgColor = dragging || active ? theme.panelHighlight : undefined
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<box
|
|
177
|
+
ref={onRef}
|
|
178
|
+
flexDirection="row"
|
|
179
|
+
paddingLeft={1}
|
|
180
|
+
paddingRight={1}
|
|
181
|
+
backgroundColor={bgColor}
|
|
182
|
+
onMouseDown={(e) => {
|
|
183
|
+
e.preventDefault()
|
|
184
|
+
onMouseDown(e)
|
|
185
|
+
}}
|
|
186
|
+
onMouseDrag={(e) => {
|
|
187
|
+
onMouseDrag(e)
|
|
188
|
+
}}
|
|
189
|
+
onMouseUp={(e) => {
|
|
190
|
+
e.preventDefault()
|
|
191
|
+
onMouseUp(e)
|
|
192
|
+
}}
|
|
193
|
+
onMouseDragEnd={(e) => {
|
|
194
|
+
onMouseDragEnd(e)
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
<text fg={indicatorColor} selectable={false}>
|
|
198
|
+
{indicator}{' '}
|
|
199
|
+
</text>
|
|
200
|
+
<text fg={labelColor} selectable={false}>
|
|
201
|
+
[{index}] {session.name}
|
|
202
|
+
</text>
|
|
203
|
+
<text fg={theme.dim} selectable={false}>
|
|
204
|
+
{' '}
|
|
205
|
+
</text>
|
|
206
|
+
</box>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
2
|
-
|
|
3
1
|
import type { TabSession } from '../../state/types'
|
|
4
2
|
|
|
3
|
+
import { useBusySpinner } from '../hooks/use-busy-spinner'
|
|
5
4
|
import { theme } from '../theme'
|
|
6
5
|
|
|
7
6
|
interface TabItemProps {
|
|
@@ -28,9 +27,6 @@ function getStatusColor(status: TabSession['status']): string {
|
|
|
28
27
|
}
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
32
|
-
const BUSY_FRAME_INTERVAL_MS = 80
|
|
33
|
-
|
|
34
30
|
function getIndicator(active: boolean, focused: boolean, inLayout: boolean): string {
|
|
35
31
|
if (active) {
|
|
36
32
|
return focused ? '›' : '•'
|
|
@@ -48,16 +44,8 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
|
|
|
48
44
|
}
|
|
49
45
|
|
|
50
46
|
function BusyIndicator() {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
const interval = setInterval(() => {
|
|
55
|
-
setFrame((prev) => (prev + 1) % BUSY_FRAMES.length)
|
|
56
|
-
}, BUSY_FRAME_INTERVAL_MS)
|
|
57
|
-
return () => clearInterval(interval)
|
|
58
|
-
}, [])
|
|
59
|
-
|
|
60
|
-
return <text fg={theme.accent}>{BUSY_FRAMES[frame]} busy</text>
|
|
47
|
+
const frame = useBusySpinner()
|
|
48
|
+
return <text fg={theme.accent}>{frame} busy</text>
|
|
61
49
|
}
|
|
62
50
|
|
|
63
51
|
function ActivityIndicator({ isFocusedInput, tab }: { tab: TabSession; isFocusedInput: boolean }) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export const BUSY_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
4
|
+
export const BUSY_FRAME_INTERVAL_MS = 80
|
|
5
|
+
|
|
6
|
+
export function useBusySpinner(enabled = true): string {
|
|
7
|
+
const [frame, setFrame] = useState(0)
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
if (!enabled) return
|
|
11
|
+
const interval = setInterval(() => {
|
|
12
|
+
setFrame((prev) => (prev + 1) % BUSY_FRAMES.length)
|
|
13
|
+
}, BUSY_FRAME_INTERVAL_MS)
|
|
14
|
+
return () => clearInterval(interval)
|
|
15
|
+
}, [enabled])
|
|
16
|
+
|
|
17
|
+
return BUSY_FRAMES[frame] ?? BUSY_FRAMES[0] ?? ''
|
|
18
|
+
}
|
package/src/ui/root.tsx
CHANGED
|
@@ -12,6 +12,7 @@ import { GitView } from './components/git-view'
|
|
|
12
12
|
import { HelpModal } from './components/help-modal'
|
|
13
13
|
import { NewTabModal } from './components/new-tab-modal'
|
|
14
14
|
import { PendingChordOverlay } from './components/pending-chord-overlay'
|
|
15
|
+
import { SessionBar } from './components/session-bar'
|
|
15
16
|
import { SessionNameModal } from './components/session-name-modal'
|
|
16
17
|
import { SessionPickerModal } from './components/session-picker-modal'
|
|
17
18
|
import { Sidebar } from './components/sidebar'
|
|
@@ -213,6 +214,7 @@ export function RootView({
|
|
|
213
214
|
const customCommands = useAppStore((s) => s.customCommands)
|
|
214
215
|
const sessions = useAppStore((s) => s.sessions)
|
|
215
216
|
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
217
|
+
const sessionBarPosition = useAppStore((s) => s.sessionBar.position)
|
|
216
218
|
|
|
217
219
|
const activeTab = tabs.find((tab) => tab.id === activeTabId)
|
|
218
220
|
const activeTree = activeTabId ? getTreeForTab(layoutTrees, tabGroupMap, activeTabId) : null
|
|
@@ -243,6 +245,7 @@ export function RootView({
|
|
|
243
245
|
|
|
244
246
|
return (
|
|
245
247
|
<box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
|
|
248
|
+
{sessionBarPosition === 'top' && <SessionBar />}
|
|
246
249
|
<box flexDirection="row" gap={0} padding={0} flexGrow={1}>
|
|
247
250
|
<Sidebar onTabActivate={onPaneActivate} />
|
|
248
251
|
{activeTree && activeTree.type === 'split' ? (
|
|
@@ -290,6 +293,7 @@ export function RootView({
|
|
|
290
293
|
/>
|
|
291
294
|
)}
|
|
292
295
|
</box>
|
|
296
|
+
{sessionBarPosition === 'bottom' && <SessionBar />}
|
|
293
297
|
<StatusBar />
|
|
294
298
|
<PendingChordOverlay />
|
|
295
299
|
{renderModal(modal, {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { SessionRecord } from '../state/types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Return sessions in user-facing display order: persisted `order` ascending,
|
|
5
|
+
* with any missing `order` falling back to `createdAt` ascending.
|
|
6
|
+
*/
|
|
7
|
+
export function orderSessionsForDisplay(sessions: SessionRecord[]): SessionRecord[] {
|
|
8
|
+
return sessions.slice().sort((a, b) => {
|
|
9
|
+
const ao = a.order ?? Number.MAX_SAFE_INTEGER
|
|
10
|
+
const bo = b.order ?? Number.MAX_SAFE_INTEGER
|
|
11
|
+
if (ao !== bo) return ao - bo
|
|
12
|
+
return a.createdAt.localeCompare(b.createdAt)
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Move `moveId` to the slot currently held by `intoPositionOfId`, shifting the
|
|
18
|
+
* displaced id in the opposite direction. Pure; returns a new array. Returns
|
|
19
|
+
* the input unchanged if either id is missing or both refer to the same slot.
|
|
20
|
+
*/
|
|
21
|
+
export function moveIdToIdPosition(
|
|
22
|
+
ids: string[],
|
|
23
|
+
moveId: string,
|
|
24
|
+
intoPositionOfId: string
|
|
25
|
+
): string[] {
|
|
26
|
+
if (moveId === intoPositionOfId) return ids
|
|
27
|
+
const from = ids.indexOf(moveId)
|
|
28
|
+
const to = ids.indexOf(intoPositionOfId)
|
|
29
|
+
if (from < 0 || to < 0) return ids
|
|
30
|
+
const next = ids.slice()
|
|
31
|
+
next.splice(from, 1)
|
|
32
|
+
next.splice(to, 0, moveId)
|
|
33
|
+
return next
|
|
34
|
+
}
|