@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.
Files changed (35) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +145 -1
  3. package/src/app-runtime/split-drag-controller.ts +3 -1
  4. package/src/app-runtime/use-terminal-resize.ts +1 -4
  5. package/src/app.tsx +2 -3
  6. package/src/config.ts +1 -12
  7. package/src/git/worktree-branch-poller.ts +54 -0
  8. package/src/input/modes/types.ts +2 -0
  9. package/src/state/layout-tree.ts +114 -4
  10. package/src/state/reducers/session-state.ts +20 -0
  11. package/src/state/reducers/tab-state.ts +20 -2
  12. package/src/state/reducers/ui-state.ts +0 -3
  13. package/src/state/session-worktrees.ts +45 -2
  14. package/src/state/store.ts +0 -3
  15. package/src/state/tab-entries.ts +74 -0
  16. package/src/state/types.ts +1 -4
  17. package/src/state/workspace-save.ts +0 -1
  18. package/src/ui/components/layout/sidebar/sidebar.tsx +9 -340
  19. package/src/ui/components/layout/sidebar/tab-item.tsx +51 -42
  20. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +10 -53
  21. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +30 -0
  22. package/src/ui/components/layout/sidebar/workspace-list.tsx +398 -0
  23. package/src/ui/components/layout/sidebar/worktree-row.tsx +92 -0
  24. package/src/ui/components/layout/split-layout.tsx +20 -39
  25. package/src/ui/components/layout/status-bar.tsx +168 -33
  26. package/src/ui/components/layout/terminal-pane.tsx +30 -0
  27. package/src/ui/components/layout/top-tab-bar.tsx +304 -0
  28. package/src/ui/components/modals/worktree/worktree-move-modal.tsx +1 -8
  29. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +22 -93
  30. package/src/ui/root.tsx +68 -63
  31. package/src/ui/status-bar-model.ts +36 -37
  32. package/src/ui/components/layout/session-bar.tsx +0 -296
  33. package/src/ui/components/layout/sidebar/sidebar-group-metadata.ts +0 -44
  34. package/src/ui/components/layout/sidebar/sidebar-scroll.ts +0 -33
  35. 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 BusyIndicator() {
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
- {frame} working
62
+ {' '}
63
+ {frame}
59
64
  </text>
60
65
  )
61
66
  }
62
67
 
63
- function WaitingIndicator() {
68
+ function WaitingGlyph() {
64
69
  const t = useTheme()
65
70
  return (
66
71
  <text fg={t.warning} selectable={false}>
67
- ? waiting
72
+ {' '}
73
+ ?
68
74
  </text>
69
75
  )
70
76
  }
71
77
 
72
- function ActivityIndicator({ tab }: { tab: TabSession }) {
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
- error
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
- restore
92
+ {' '}
93
+
86
94
  </text>
87
95
  )
88
96
  }
89
97
 
90
98
  if (tab.activity === 'working') {
91
- return <BusyIndicator />
99
+ return <BusyGlyph />
92
100
  }
93
101
 
94
102
  if (tab.activity === 'waiting-input') {
95
- return <WaitingIndicator />
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
- idle
109
+ {' '}
110
+
102
111
  </text>
103
112
  )
104
113
  }
105
114
 
106
115
  return (
107
116
  <text fg={getStatusColor(tab.status)} selectable={false}>
108
- {tab.status}
117
+ {' '}
118
+ ·
109
119
  </text>
110
120
  )
111
121
  }
112
122
 
113
- export function TabItem({ active, focused, id, inLayout, moveWorktreeId, tab }: TabItemProps) {
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 up',
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 down',
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
- paddingTop={0}
185
- paddingBottom={0}
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
- <box flexDirection="row" alignItems="center">
193
- <text fg={indicatorColor} selectable={false}>
194
- {indicator}{' '}
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
- <box flexGrow={1}>
197
- <text fg={active ? t.text : t.textMuted} selectable={false}>
198
- {tab.title}
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
- {hovered ? (
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, useRef } from 'react'
3
+ import { useEffect } from 'react'
4
4
 
5
- import { getSidebarScrollTarget } from './sidebar-scroll'
6
-
7
- interface UseSidebarAutoScrollOptions {
5
+ interface Options {
8
6
  scrollRef: React.RefObject<ScrollBoxRenderable | null>
9
7
  visible: boolean
10
- activeTabId: string | null
11
- activeIndex: number
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
- previousVisibilityRef.current = false
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
- previousVisibilityRef.current = visible
35
- previousActiveIndexRef.current = activeIndex
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
+ })