@brimveyn/aimux 1.14.0 → 1.14.2
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/package.json +2 -2
- package/src/app-runtime/side-effects.ts +145 -1
- package/src/app-runtime/split-drag-controller.ts +3 -1
- package/src/app-runtime/use-terminal-resize.ts +1 -4
- package/src/app.tsx +2 -3
- package/src/config.ts +1 -12
- package/src/git/worktree-branch-poller.ts +54 -0
- package/src/input/modes/types.ts +2 -0
- package/src/state/layout-tree.ts +114 -4
- package/src/state/reducers/session-state.ts +20 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +0 -3
- package/src/state/session-worktrees.ts +45 -2
- package/src/state/store.ts +0 -3
- package/src/state/tab-entries.ts +74 -0
- package/src/state/types.ts +1 -4
- package/src/state/workspace-save.ts +0 -1
- package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
- package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
- package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
- package/src/ui/components/layout/split-layout.tsx +20 -39
- package/src/ui/components/layout/status-bar.tsx +168 -33
- package/src/ui/components/layout/terminal-pane.tsx +30 -0
- package/src/ui/components/layout/top-tab-bar.tsx +304 -0
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +22 -93
- package/src/ui/root.tsx +68 -63
- package/src/ui/status-bar-model.ts +36 -37
- package/src/ui/components/layout/session-bar.tsx +0 -296
- package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
- package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +0 -36
|
@@ -17,6 +17,10 @@ interface TabItemProps {
|
|
|
17
17
|
inLayout?: boolean
|
|
18
18
|
/** When set, this tab's worktree can be moved — adds a "Move worktree" entry. */
|
|
19
19
|
moveWorktreeId?: string
|
|
20
|
+
/** 1-based position in the visible tab list — rendered as `[N]` and matches Leader+N. */
|
|
21
|
+
indexLabel?: string
|
|
22
|
+
/** Render the close × unconditionally instead of only on hover. */
|
|
23
|
+
alwaysShowClose?: boolean
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
function getStatusColor(status: TabSession['status']): string {
|
|
@@ -50,31 +54,34 @@ function getIndicatorColor(active: boolean, focused: boolean, inLayout: boolean)
|
|
|
50
54
|
return inLayout ? t.textMuted : t.textMuted
|
|
51
55
|
}
|
|
52
56
|
|
|
53
|
-
function
|
|
57
|
+
function BusyGlyph() {
|
|
54
58
|
const t = useTheme()
|
|
55
59
|
const frame = useBusySpinner()
|
|
56
60
|
return (
|
|
57
61
|
<text fg={t.primary} selectable={false}>
|
|
58
|
-
{
|
|
62
|
+
{' '}
|
|
63
|
+
{frame}
|
|
59
64
|
</text>
|
|
60
65
|
)
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
function
|
|
68
|
+
function WaitingGlyph() {
|
|
64
69
|
const t = useTheme()
|
|
65
70
|
return (
|
|
66
71
|
<text fg={t.warning} selectable={false}>
|
|
67
|
-
|
|
72
|
+
{' '}
|
|
73
|
+
?
|
|
68
74
|
</text>
|
|
69
75
|
)
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
function
|
|
78
|
+
function ActivityGlyph({ tab }: { tab: TabSession }) {
|
|
73
79
|
const t = useTheme()
|
|
74
80
|
if (tab.status === 'error') {
|
|
75
81
|
return (
|
|
76
82
|
<text fg={t.error} selectable={false}>
|
|
77
|
-
|
|
83
|
+
{' '}
|
|
84
|
+
✗
|
|
78
85
|
</text>
|
|
79
86
|
)
|
|
80
87
|
}
|
|
@@ -82,37 +89,48 @@ function ActivityIndicator({ tab }: { tab: TabSession }) {
|
|
|
82
89
|
if (tab.status === 'disconnected') {
|
|
83
90
|
return (
|
|
84
91
|
<text fg={t.warning} selectable={false}>
|
|
85
|
-
|
|
92
|
+
{' '}
|
|
93
|
+
⏸
|
|
86
94
|
</text>
|
|
87
95
|
)
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
if (tab.activity === 'working') {
|
|
91
|
-
return <
|
|
99
|
+
return <BusyGlyph />
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
if (tab.activity === 'waiting-input') {
|
|
95
|
-
return <
|
|
103
|
+
return <WaitingGlyph />
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
if (tab.activity === 'idle') {
|
|
99
107
|
return (
|
|
100
108
|
<text fg={t.success} selectable={false}>
|
|
101
|
-
|
|
109
|
+
{' '}
|
|
110
|
+
●
|
|
102
111
|
</text>
|
|
103
112
|
)
|
|
104
113
|
}
|
|
105
114
|
|
|
106
115
|
return (
|
|
107
116
|
<text fg={getStatusColor(tab.status)} selectable={false}>
|
|
108
|
-
{
|
|
117
|
+
{' '}
|
|
118
|
+
·
|
|
109
119
|
</text>
|
|
110
120
|
)
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
export function TabItem({
|
|
123
|
+
export function TabItem({
|
|
124
|
+
active,
|
|
125
|
+
alwaysShowClose,
|
|
126
|
+
focused,
|
|
127
|
+
id,
|
|
128
|
+
indexLabel,
|
|
129
|
+
inLayout,
|
|
130
|
+
moveWorktreeId,
|
|
131
|
+
tab,
|
|
132
|
+
}: TabItemProps) {
|
|
114
133
|
const t = useTheme()
|
|
115
|
-
const label = tab.command.split(' ')[0]
|
|
116
134
|
const isInLayout = inLayout ?? false
|
|
117
135
|
const indicator = getIndicator(active, focused, isInLayout)
|
|
118
136
|
const indicatorColor = getIndicatorColor(active, focused, isInLayout)
|
|
@@ -135,21 +153,19 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
|
|
|
135
153
|
},
|
|
136
154
|
],
|
|
137
155
|
[
|
|
138
|
-
'Move
|
|
156
|
+
'Move left',
|
|
139
157
|
() => {
|
|
140
158
|
dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
|
|
141
159
|
dispatchGlobal({ delta: -1, type: 'reorder-active-tab' })
|
|
142
160
|
},
|
|
143
161
|
],
|
|
144
162
|
[
|
|
145
|
-
'Move
|
|
163
|
+
'Move right',
|
|
146
164
|
() => {
|
|
147
165
|
dispatchGlobal({ tabId: tab.id, type: 'set-active-tab' })
|
|
148
166
|
dispatchGlobal({ delta: 1, type: 'reorder-active-tab' })
|
|
149
167
|
},
|
|
150
168
|
],
|
|
151
|
-
// Move this tab's worktree into another one. Opened from here it overlays
|
|
152
|
-
// the normal view (no git mode) since the open action doesn't touch focus.
|
|
153
169
|
...(moveWorktreeId != null && moveWorktreeId !== ''
|
|
154
170
|
? [
|
|
155
171
|
[
|
|
@@ -181,38 +197,31 @@ export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }:
|
|
|
181
197
|
id={id}
|
|
182
198
|
paddingLeft={1}
|
|
183
199
|
paddingRight={1}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
flexDirection="column"
|
|
187
|
-
gap={0}
|
|
200
|
+
flexDirection="row"
|
|
201
|
+
alignItems="center"
|
|
188
202
|
rightClickMenu={rightClickMenu}
|
|
189
203
|
onMouseOver={handleMouseOver}
|
|
190
204
|
onMouseOut={handleMouseOut}
|
|
191
205
|
>
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
206
|
+
<text fg={indicatorColor} selectable={false}>
|
|
207
|
+
{indicator}{' '}
|
|
208
|
+
</text>
|
|
209
|
+
{indexLabel != null && indexLabel !== '' ? (
|
|
210
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
211
|
+
{indexLabel}{' '}
|
|
195
212
|
</text>
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
) : null}
|
|
214
|
+
<text fg={active ? t.text : t.textMuted} selectable={false} wrapMode="none">
|
|
215
|
+
{tab.title}
|
|
216
|
+
</text>
|
|
217
|
+
<ActivityGlyph tab={tab} />
|
|
218
|
+
{alwaysShowClose === true || hovered ? (
|
|
219
|
+
<box paddingLeft={1} onMouseDown={handleCloseMouseDown}>
|
|
220
|
+
<text fg={t.textMuted} selectable={false}>
|
|
221
|
+
×
|
|
199
222
|
</text>
|
|
200
223
|
</box>
|
|
201
|
-
|
|
202
|
-
<box onMouseDown={handleCloseMouseDown}>
|
|
203
|
-
<text fg={t.textMuted} selectable={false}>
|
|
204
|
-
×
|
|
205
|
-
</text>
|
|
206
|
-
</box>
|
|
207
|
-
) : null}
|
|
208
|
-
</box>
|
|
209
|
-
<box flexDirection="row">
|
|
210
|
-
<text fg={t.textMuted} selectable={false}>
|
|
211
|
-
{' '}
|
|
212
|
-
{label}{' '}
|
|
213
|
-
</text>
|
|
214
|
-
<ActivityIndicator tab={tab} />
|
|
215
|
-
</box>
|
|
224
|
+
) : null}
|
|
216
225
|
</ContextMenuBox>
|
|
217
226
|
)
|
|
218
227
|
}
|
|
@@ -1,63 +1,20 @@
|
|
|
1
1
|
import type { ScrollBoxRenderable } from '@opentui/core'
|
|
2
2
|
|
|
3
|
-
import { useEffect
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
interface UseSidebarAutoScrollOptions {
|
|
5
|
+
interface Options {
|
|
8
6
|
scrollRef: React.RefObject<ScrollBoxRenderable | null>
|
|
9
7
|
visible: boolean
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
tabCount: number
|
|
8
|
+
/** Full id of the active row to bring into view (workspace OR worktree). */
|
|
9
|
+
activeRowId: string | null
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
export function useSidebarAutoScroll({
|
|
16
|
-
activeIndex,
|
|
17
|
-
activeTabId,
|
|
18
|
-
scrollRef,
|
|
19
|
-
tabCount,
|
|
20
|
-
visible,
|
|
21
|
-
}: UseSidebarAutoScrollOptions): void {
|
|
22
|
-
const previousActiveIndexRef = useRef(-1)
|
|
23
|
-
const previousVisibilityRef = useRef(visible)
|
|
24
|
-
|
|
12
|
+
export function useSidebarAutoScroll({ activeRowId, scrollRef, visible }: Options): void {
|
|
25
13
|
useEffect(() => {
|
|
26
|
-
if (!visible)
|
|
27
|
-
|
|
28
|
-
previousActiveIndexRef.current = activeIndex
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
14
|
+
if (!visible) return
|
|
15
|
+
if (activeRowId == null || activeRowId === '') return
|
|
32
16
|
const scrollbox = scrollRef.current
|
|
33
|
-
if (!scrollbox)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (!previousVisibilityRef.current && activeTabId != null && activeTabId !== '') {
|
|
40
|
-
scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
|
|
41
|
-
previousVisibilityRef.current = true
|
|
42
|
-
previousActiveIndexRef.current = activeIndex
|
|
43
|
-
return
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const scrollTarget = getSidebarScrollTarget({
|
|
47
|
-
nextActiveIndex: activeIndex,
|
|
48
|
-
previousActiveIndex: previousActiveIndexRef.current,
|
|
49
|
-
tabCount,
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
if (scrollTarget === 'top') {
|
|
53
|
-
scrollbox.scrollTo({ x: 0, y: 0 })
|
|
54
|
-
} else if (scrollTarget === 'bottom') {
|
|
55
|
-
scrollbox.scrollTo({ x: 0, y: scrollbox.scrollHeight })
|
|
56
|
-
} else if (scrollTarget === 'active-item' && activeTabId != null && activeTabId !== '') {
|
|
57
|
-
scrollbox.scrollChildIntoView(`sidebar-tab-${activeTabId}`)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
previousVisibilityRef.current = visible
|
|
61
|
-
previousActiveIndexRef.current = activeIndex
|
|
62
|
-
}, [activeIndex, activeTabId, scrollRef, tabCount, visible])
|
|
17
|
+
if (!scrollbox) return
|
|
18
|
+
scrollbox.scrollChildIntoView(activeRowId)
|
|
19
|
+
}, [activeRowId, scrollRef, visible])
|
|
63
20
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
interface Options {
|
|
6
|
+
scrollRef: React.RefObject<ScrollBoxRenderable | null>
|
|
7
|
+
visible: boolean
|
|
8
|
+
activeTabId: string | null
|
|
9
|
+
idPrefix: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Scroll the active tab into view whenever the active tab id changes (and on
|
|
14
|
+
* first reveal). The top bar is a single horizontal row, so this is just a
|
|
15
|
+
* thin wrapper around `scrollChildIntoView` — no wrap-around/edge heuristics.
|
|
16
|
+
*/
|
|
17
|
+
export function useTopTabBarAutoScroll({
|
|
18
|
+
activeTabId,
|
|
19
|
+
idPrefix,
|
|
20
|
+
scrollRef,
|
|
21
|
+
visible,
|
|
22
|
+
}: Options): void {
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!visible) return
|
|
25
|
+
if (activeTabId == null || activeTabId === '') return
|
|
26
|
+
const scrollbox = scrollRef.current
|
|
27
|
+
if (!scrollbox) return
|
|
28
|
+
scrollbox.scrollChildIntoView(`${idPrefix}${activeTabId}`)
|
|
29
|
+
}, [activeTabId, idPrefix, scrollRef, visible])
|
|
30
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
MouseEvent as OtuiMouseEvent,
|
|
4
|
+
ScrollBoxRenderable,
|
|
5
|
+
} from '@opentui/core'
|
|
6
|
+
|
|
7
|
+
import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'
|
|
8
|
+
|
|
9
|
+
import type { SessionRecord, SessionStatus, WorktreeRecord } from '../../../../state/types'
|
|
10
|
+
|
|
11
|
+
import { useAppStore } from '../../../../state/app-store'
|
|
12
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
|
|
13
|
+
import { formatDivergence, getWorktreeColor } from '../../../../state/session-worktrees'
|
|
14
|
+
// eslint-disable-next-line no-duplicate-imports
|
|
15
|
+
import { IDLE_SESSION_STATUS } from '../../../../state/types'
|
|
16
|
+
import { useBusySpinner } from '../../../hooks/use-busy-spinner'
|
|
17
|
+
import { moveIdToIdPosition, orderSessionsForDisplay } from '../../../session-ordering'
|
|
18
|
+
import { useTheme } from '../../../theme'
|
|
19
|
+
import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
|
|
20
|
+
import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
|
|
21
|
+
import { WorktreeRow } from './worktree-row'
|
|
22
|
+
|
|
23
|
+
interface WorkspaceListProps {
|
|
24
|
+
contentWidth: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
|
|
28
|
+
|
|
29
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
30
|
+
if (a.length !== b.length) return false
|
|
31
|
+
for (let i = 0; i < a.length; i++) {
|
|
32
|
+
if (a[i] !== b[i]) return false
|
|
33
|
+
}
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function truncate(label: string, max: number): string {
|
|
38
|
+
if (max <= 0) return ''
|
|
39
|
+
if (label.length <= max) return label
|
|
40
|
+
if (max === 1) return '…'
|
|
41
|
+
return `${label.slice(0, max - 1)}…`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
|
|
45
|
+
const t = useTheme()
|
|
46
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
47
|
+
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
48
|
+
const statusMap = useAppStore((s) => s.sessionStatuses)
|
|
49
|
+
|
|
50
|
+
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
51
|
+
const [dragOrder, setDragOrder] = useState<string[] | null>(null)
|
|
52
|
+
const lastSwapWithRef = useRef<string | null>(null)
|
|
53
|
+
const rowRefs = useRef(new Map<string, BoxRenderable>())
|
|
54
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
55
|
+
|
|
56
|
+
const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
|
|
57
|
+
const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
|
|
58
|
+
|
|
59
|
+
const currentSession = useMemo(
|
|
60
|
+
() =>
|
|
61
|
+
currentSessionId != null && currentSessionId !== ''
|
|
62
|
+
? sessions.find((s) => s.id === currentSessionId)
|
|
63
|
+
: undefined,
|
|
64
|
+
[currentSessionId, sessions]
|
|
65
|
+
)
|
|
66
|
+
// The active row can be either a worktree row OR the workspace row
|
|
67
|
+
// (when the primary worktree is active). Both must scroll into view —
|
|
68
|
+
// otherwise the cursor visually "disappears" off-screen when crossing
|
|
69
|
+
// a workspace boundary on a key press.
|
|
70
|
+
const currentWorktrees = currentSession?.worktrees ?? []
|
|
71
|
+
const currentPrimary = currentWorktrees.find((w) => w.source === 'primary') ?? currentWorktrees[0]
|
|
72
|
+
const rawActiveWorktreeId = currentSession?.activeWorktreeId
|
|
73
|
+
const activeOnNonPrimary =
|
|
74
|
+
rawActiveWorktreeId != null &&
|
|
75
|
+
rawActiveWorktreeId !== '' &&
|
|
76
|
+
rawActiveWorktreeId !== currentPrimary?.id
|
|
77
|
+
let activeRowId: string | null = null
|
|
78
|
+
if (activeOnNonPrimary) {
|
|
79
|
+
activeRowId = `sidebar-wt-${rawActiveWorktreeId}`
|
|
80
|
+
} else if (currentSessionId != null && currentSessionId !== '') {
|
|
81
|
+
activeRowId = `sidebar-ws-${currentSessionId}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
useSidebarAutoScroll({
|
|
85
|
+
activeRowId,
|
|
86
|
+
scrollRef,
|
|
87
|
+
visible: true,
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
|
|
91
|
+
if (ref) rowRefs.current.set(id, ref)
|
|
92
|
+
else rowRefs.current.delete(id)
|
|
93
|
+
}, [])
|
|
94
|
+
|
|
95
|
+
const findRowAtY = useCallback((y: number): string | null => {
|
|
96
|
+
for (const [id, ref] of rowRefs.current) {
|
|
97
|
+
if (y >= ref.y && y < ref.y + ref.height) return id
|
|
98
|
+
}
|
|
99
|
+
return null
|
|
100
|
+
}, [])
|
|
101
|
+
|
|
102
|
+
const handleRowDragStart = useCallback(
|
|
103
|
+
(id: string) => {
|
|
104
|
+
setDraggingId(id)
|
|
105
|
+
setDragOrder(baselineOrder)
|
|
106
|
+
lastSwapWithRef.current = null
|
|
107
|
+
},
|
|
108
|
+
[baselineOrder]
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const handleRowDrag = useCallback(
|
|
112
|
+
(event: OtuiMouseEvent) => {
|
|
113
|
+
if (!(draggingId != null && draggingId !== '')) return
|
|
114
|
+
const hit = findRowAtY(event.y)
|
|
115
|
+
if (hit === null) {
|
|
116
|
+
lastSwapWithRef.current = null
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
if (hit === draggingId) {
|
|
120
|
+
lastSwapWithRef.current = null
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
if (hit === lastSwapWithRef.current) return
|
|
124
|
+
setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
|
|
125
|
+
lastSwapWithRef.current = hit
|
|
126
|
+
},
|
|
127
|
+
[draggingId, findRowAtY]
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const commitDrop = useCallback(() => {
|
|
131
|
+
const source = draggingId
|
|
132
|
+
const finalOrder = dragOrder
|
|
133
|
+
setDraggingId(null)
|
|
134
|
+
setDragOrder(null)
|
|
135
|
+
lastSwapWithRef.current = null
|
|
136
|
+
|
|
137
|
+
if (source == null || source === '' || !finalOrder) return
|
|
138
|
+
|
|
139
|
+
const changed = !arraysEqual(finalOrder, baselineOrder)
|
|
140
|
+
if (changed) {
|
|
141
|
+
dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const idx = baselineOrder.indexOf(source)
|
|
146
|
+
if (idx >= 0) {
|
|
147
|
+
runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
|
|
148
|
+
}
|
|
149
|
+
}, [baselineOrder, dragOrder, draggingId])
|
|
150
|
+
|
|
151
|
+
const cancelDrag = useCallback(() => {
|
|
152
|
+
setDraggingId(null)
|
|
153
|
+
setDragOrder(null)
|
|
154
|
+
lastSwapWithRef.current = null
|
|
155
|
+
}, [])
|
|
156
|
+
|
|
157
|
+
const handleNewSession = useCallback((e: OtuiMouseEvent) => {
|
|
158
|
+
e.stopPropagation()
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
|
|
161
|
+
}, [])
|
|
162
|
+
|
|
163
|
+
const visibleSessions =
|
|
164
|
+
dragOrder !== null
|
|
165
|
+
? dragOrder
|
|
166
|
+
.map((id) => ordered.find((s) => s.id === id))
|
|
167
|
+
.filter((s): s is SessionRecord => !!s)
|
|
168
|
+
: ordered
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
172
|
+
<scrollbox
|
|
173
|
+
ref={scrollRef}
|
|
174
|
+
scrollY
|
|
175
|
+
flexGrow={1}
|
|
176
|
+
flexShrink={1}
|
|
177
|
+
contentOptions={COLUMN_CONTENT_OPTIONS}
|
|
178
|
+
>
|
|
179
|
+
{(() => {
|
|
180
|
+
// Build a single flat list of items — workspace rows interleaved
|
|
181
|
+
// with their non-primary worktrees. One map, one React keypath per
|
|
182
|
+
// visible row; transitions are a single atomic reconciliation.
|
|
183
|
+
const rows: ReactNode[] = []
|
|
184
|
+
for (const [visibleIdx, session] of visibleSessions.entries()) {
|
|
185
|
+
const sessionIndex = baselineOrder.indexOf(session.id) + 1
|
|
186
|
+
const isCurrentSession = session.id === currentSessionId
|
|
187
|
+
const worktrees = session.worktrees ?? []
|
|
188
|
+
const primaryWorktree = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
|
|
189
|
+
const extraWorktrees = worktrees.filter((w) => w.id !== primaryWorktree?.id)
|
|
190
|
+
const workspaceIsActiveItem =
|
|
191
|
+
isCurrentSession &&
|
|
192
|
+
(session.activeWorktreeId == null ||
|
|
193
|
+
session.activeWorktreeId === '' ||
|
|
194
|
+
session.activeWorktreeId === primaryWorktree?.id)
|
|
195
|
+
rows.push(
|
|
196
|
+
<WorkspaceRow
|
|
197
|
+
key={`ws:${session.id}`}
|
|
198
|
+
session={session}
|
|
199
|
+
isActiveItem={workspaceIsActiveItem}
|
|
200
|
+
inCurrentGroup={isCurrentSession}
|
|
201
|
+
primaryWorktree={primaryWorktree}
|
|
202
|
+
status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
|
|
203
|
+
dragging={draggingId === session.id}
|
|
204
|
+
contentWidth={contentWidth}
|
|
205
|
+
marginTop={visibleIdx > 0 ? 1 : 0}
|
|
206
|
+
setRowRef={setRowRef}
|
|
207
|
+
onDragStart={handleRowDragStart}
|
|
208
|
+
onDrag={handleRowDrag}
|
|
209
|
+
onDrop={commitDrop}
|
|
210
|
+
onDragCancel={cancelDrag}
|
|
211
|
+
/>
|
|
212
|
+
)
|
|
213
|
+
for (const worktree of extraWorktrees) {
|
|
214
|
+
rows.push(
|
|
215
|
+
<WorktreeRow
|
|
216
|
+
key={`wt:${worktree.id}`}
|
|
217
|
+
session={session}
|
|
218
|
+
worktree={worktree}
|
|
219
|
+
sessionIndex={sessionIndex}
|
|
220
|
+
isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
|
|
221
|
+
inCurrentGroup={isCurrentSession}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return rows
|
|
227
|
+
})()}
|
|
228
|
+
</scrollbox>
|
|
229
|
+
<box
|
|
230
|
+
flexDirection="row"
|
|
231
|
+
flexShrink={0}
|
|
232
|
+
marginTop={1}
|
|
233
|
+
backgroundColor={t.backgroundPanel}
|
|
234
|
+
justifyContent="center"
|
|
235
|
+
onMouseDown={handleNewSession}
|
|
236
|
+
>
|
|
237
|
+
<text fg={t.text} selectable={false}>
|
|
238
|
+
+ New workspace
|
|
239
|
+
</text>
|
|
240
|
+
</box>
|
|
241
|
+
</box>
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
interface WorkspaceRowProps {
|
|
246
|
+
session: SessionRecord
|
|
247
|
+
/** True when this row is the active cursor item (workspace's primary active). */
|
|
248
|
+
isActiveItem: boolean
|
|
249
|
+
/** True when this row belongs to the current workspace (selection scope). */
|
|
250
|
+
inCurrentGroup: boolean
|
|
251
|
+
/** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
|
|
252
|
+
primaryWorktree: WorktreeRecord | undefined
|
|
253
|
+
status: SessionStatus
|
|
254
|
+
dragging: boolean
|
|
255
|
+
contentWidth: number
|
|
256
|
+
/** Vertical spacing above this row — used to separate workspace blocks. */
|
|
257
|
+
marginTop: number
|
|
258
|
+
setRowRef: (id: string, ref: BoxRenderable | null) => void
|
|
259
|
+
onDragStart: (id: string) => void
|
|
260
|
+
onDrag: (event: OtuiMouseEvent) => void
|
|
261
|
+
onDrop: () => void
|
|
262
|
+
onDragCancel: () => void
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const WorkspaceRow = memo(function WorkspaceRow({
|
|
266
|
+
contentWidth,
|
|
267
|
+
dragging,
|
|
268
|
+
inCurrentGroup,
|
|
269
|
+
isActiveItem,
|
|
270
|
+
marginTop,
|
|
271
|
+
onDrag,
|
|
272
|
+
onDragCancel,
|
|
273
|
+
onDragStart,
|
|
274
|
+
onDrop,
|
|
275
|
+
primaryWorktree,
|
|
276
|
+
session,
|
|
277
|
+
setRowRef,
|
|
278
|
+
status,
|
|
279
|
+
}: WorkspaceRowProps) {
|
|
280
|
+
const t = useTheme()
|
|
281
|
+
const showSpinner = status.working
|
|
282
|
+
const showWaiting = status.waiting
|
|
283
|
+
const spinner = useBusySpinner(showSpinner)
|
|
284
|
+
let bgColor: string | undefined
|
|
285
|
+
if (dragging || isActiveItem) {
|
|
286
|
+
bgColor = t.backgroundElement
|
|
287
|
+
} else if (inCurrentGroup) {
|
|
288
|
+
bgColor = t.backgroundPanel
|
|
289
|
+
}
|
|
290
|
+
const workingColor = t.primary
|
|
291
|
+
const waitingColor = t.warning
|
|
292
|
+
const divergence = useAppStore((s) =>
|
|
293
|
+
primaryWorktree ? s.worktreeDivergence[primaryWorktree.id] : undefined
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const handleRef = useCallback(
|
|
297
|
+
(r: BoxRenderable | null) => setRowRef(session.id, r),
|
|
298
|
+
[setRowRef, session.id]
|
|
299
|
+
)
|
|
300
|
+
const handleMouseDown = useCallback(
|
|
301
|
+
(e: OtuiMouseEvent) => {
|
|
302
|
+
e.preventDefault()
|
|
303
|
+
e.stopPropagation()
|
|
304
|
+
onDragStart(session.id)
|
|
305
|
+
},
|
|
306
|
+
[onDragStart, session.id]
|
|
307
|
+
)
|
|
308
|
+
const handleMouseUp = useCallback(
|
|
309
|
+
(e: OtuiMouseEvent) => {
|
|
310
|
+
e.preventDefault()
|
|
311
|
+
onDrop()
|
|
312
|
+
},
|
|
313
|
+
[onDrop]
|
|
314
|
+
)
|
|
315
|
+
const rightClickMenu = useMemo<[string, () => void][]>(
|
|
316
|
+
() => [
|
|
317
|
+
[
|
|
318
|
+
'Rename workspace',
|
|
319
|
+
() =>
|
|
320
|
+
dispatchGlobal({
|
|
321
|
+
initialName: session.name,
|
|
322
|
+
returnToSessionPicker: false,
|
|
323
|
+
sessionTargetId: session.id,
|
|
324
|
+
type: 'open-session-name-modal',
|
|
325
|
+
}),
|
|
326
|
+
],
|
|
327
|
+
[
|
|
328
|
+
'Delete workspace',
|
|
329
|
+
() => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
|
|
330
|
+
],
|
|
331
|
+
],
|
|
332
|
+
[session.id, session.name]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
// Color of the left vertical bar — the workspace's stable accent (derived
|
|
336
|
+
// from its primary worktree id). Falls back to a tint for unhydrated sessions.
|
|
337
|
+
const barColor =
|
|
338
|
+
primaryWorktree != null
|
|
339
|
+
? (primaryWorktree.color ?? getWorktreeColor(primaryWorktree.id))
|
|
340
|
+
: t.textMuted
|
|
341
|
+
// Working/waiting indicator overrides the colored bar — the user needs to
|
|
342
|
+
// see assistant activity from across the room.
|
|
343
|
+
let leadingGlyph = '▍'
|
|
344
|
+
let leadingColor = barColor
|
|
345
|
+
if (showWaiting) {
|
|
346
|
+
leadingGlyph = '?'
|
|
347
|
+
leadingColor = waitingColor
|
|
348
|
+
} else if (showSpinner) {
|
|
349
|
+
leadingGlyph = spinner
|
|
350
|
+
leadingColor = workingColor
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const branchText = primaryWorktree?.branch ?? ''
|
|
354
|
+
const divergenceText = formatDivergence(divergence)
|
|
355
|
+
const showBranch = branchText !== ''
|
|
356
|
+
const nameLabel = truncate(session.name, Math.max(0, contentWidth - 4))
|
|
357
|
+
const branchLabel = truncate(
|
|
358
|
+
branchText,
|
|
359
|
+
Math.max(0, contentWidth - 5 - (divergenceText.length + 1))
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<ContextMenuBox
|
|
364
|
+
ref={handleRef}
|
|
365
|
+
id={`sidebar-ws-${session.id}`}
|
|
366
|
+
flexDirection="column"
|
|
367
|
+
flexShrink={0}
|
|
368
|
+
marginTop={marginTop}
|
|
369
|
+
paddingLeft={1}
|
|
370
|
+
paddingRight={1}
|
|
371
|
+
backgroundColor={bgColor}
|
|
372
|
+
rightClickMenu={rightClickMenu}
|
|
373
|
+
onMouseDown={handleMouseDown}
|
|
374
|
+
onMouseDrag={onDrag}
|
|
375
|
+
onMouseUp={handleMouseUp}
|
|
376
|
+
onMouseDragEnd={onDragCancel}
|
|
377
|
+
>
|
|
378
|
+
<box flexDirection="row" alignItems="center">
|
|
379
|
+
<text fg={leadingColor} selectable={false} wrapMode="none">
|
|
380
|
+
{leadingGlyph}
|
|
381
|
+
</text>
|
|
382
|
+
<text fg={isActiveItem ? t.text : t.textMuted} selectable={false} wrapMode="none">
|
|
383
|
+
{' '}
|
|
384
|
+
{nameLabel}
|
|
385
|
+
</text>
|
|
386
|
+
</box>
|
|
387
|
+
{showBranch ? (
|
|
388
|
+
<box flexDirection="row">
|
|
389
|
+
<text fg={t.textMuted} selectable={false} wrapMode="none">
|
|
390
|
+
{' '}
|
|
391
|
+
{'\u{e702}'} {branchLabel}
|
|
392
|
+
{divergenceText !== '' ? ` ${divergenceText}` : ''}
|
|
393
|
+
</text>
|
|
394
|
+
</box>
|
|
395
|
+
) : null}
|
|
396
|
+
</ContextMenuBox>
|
|
397
|
+
)
|
|
398
|
+
})
|