@brimveyn/aimux 1.21.0 → 1.22.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 (220) hide show
  1. package/README.md +27 -25
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +22 -27
  4. package/src/app-runtime/backend-attach-runtime.ts +43 -36
  5. package/src/app-runtime/backend-runtime-events.ts +107 -88
  6. package/src/app-runtime/editor-actions.ts +201 -0
  7. package/src/app-runtime/git-actions.ts +197 -0
  8. package/src/app-runtime/measure-ref.ts +19 -0
  9. package/src/app-runtime/navigation-actions.ts +213 -0
  10. package/src/app-runtime/project-actions.ts +188 -0
  11. package/src/app-runtime/prompt-injection.ts +123 -0
  12. package/src/app-runtime/selection.ts +29 -0
  13. package/src/app-runtime/settings-actions.ts +156 -0
  14. package/src/app-runtime/setup-actions.ts +237 -0
  15. package/src/app-runtime/side-effect-context.ts +33 -0
  16. package/src/app-runtime/side-effects.ts +302 -1541
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/tab-actions.ts +218 -0
  19. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  20. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  21. package/src/app-runtime/use-backend-runtime.ts +12 -11
  22. package/src/app-runtime/use-directory-search.ts +5 -4
  23. package/src/app-runtime/use-mouse-handlers.ts +2 -1
  24. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  25. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  26. package/src/app-runtime/use-setup-runner.ts +96 -0
  27. package/src/app-runtime/use-terminal-resize.ts +18 -14
  28. package/src/app-runtime/workspace-actions.ts +366 -0
  29. package/src/app-runtime/workspace-activity.ts +138 -0
  30. package/src/app-runtime/workspace-naming.ts +90 -0
  31. package/src/app.tsx +93 -48
  32. package/src/assets/sounds/bell.wav +0 -0
  33. package/src/assets/sounds/ding.wav +0 -0
  34. package/src/assets/sounds/plane.wav +0 -0
  35. package/src/cli/client/daemon-client.ts +2 -2
  36. package/src/cli/client/project-resolver.ts +112 -0
  37. package/src/cli/commands/project/close.ts +32 -0
  38. package/src/cli/commands/project/create.ts +93 -0
  39. package/src/cli/commands/project/list.ts +25 -0
  40. package/src/cli/commands/project/show.ts +32 -0
  41. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  42. package/src/cli/commands/tab/await.ts +2 -2
  43. package/src/cli/commands/tab/close.ts +3 -3
  44. package/src/cli/commands/tab/create.ts +74 -73
  45. package/src/cli/commands/tab/focus.ts +3 -3
  46. package/src/cli/commands/tab/list.ts +6 -6
  47. package/src/cli/commands/tab/run.ts +2 -2
  48. package/src/cli/commands/tab/send.ts +2 -2
  49. package/src/cli/commands/tab/snapshot.ts +2 -2
  50. package/src/cli/commands/tab/tail.ts +2 -2
  51. package/src/cli/commands/tab/wait.ts +2 -2
  52. package/src/cli/commands/worker/await.ts +3 -3
  53. package/src/cli/commands/worker/doctor.ts +30 -30
  54. package/src/cli/commands/worker/list.ts +15 -15
  55. package/src/cli/commands/worker/prompt.ts +3 -3
  56. package/src/cli/commands/worker/run.ts +23 -23
  57. package/src/cli/commands/worker/shared.ts +53 -59
  58. package/src/cli/commands/worker/stop.ts +29 -29
  59. package/src/cli/commands/worker/submit.ts +3 -3
  60. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  61. package/src/cli/commands/workspace/create.ts +30 -68
  62. package/src/cli/commands/workspace/list.ts +52 -9
  63. package/src/cli/commands/workspace/remove.ts +81 -0
  64. package/src/cli/completion/plan.ts +2 -2
  65. package/src/cli/completion/sources.ts +18 -18
  66. package/src/cli/context.ts +13 -13
  67. package/src/cli/flags.ts +30 -10
  68. package/src/cli/index.ts +26 -16
  69. package/src/cli/output.ts +1 -1
  70. package/src/cli/registry.ts +12 -12
  71. package/src/config/loader.ts +16 -5
  72. package/src/config.ts +70 -112
  73. package/src/daemon/catalog-writer.ts +67 -67
  74. package/src/daemon/daemon.ts +205 -201
  75. package/src/daemon/session-manager.ts +39 -39
  76. package/src/daemon/session-registry.ts +14 -14
  77. package/src/git/divergence.ts +33 -2
  78. package/src/git/git-poller.ts +1 -1
  79. package/src/git/git-status.ts +2 -2
  80. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  81. package/src/git/pr-status.ts +1 -1
  82. package/src/git/repo-discovery.ts +1 -1
  83. package/src/git/use-repo-discovery.ts +1 -1
  84. package/src/git/workspace-branch-poller.ts +54 -0
  85. package/src/git/workspace-divergence-poller.ts +65 -0
  86. package/src/git/worktree.ts +29 -0
  87. package/src/index.tsx +4 -4
  88. package/src/input/keymap/help-entries.ts +7 -5
  89. package/src/input/modes/bridge.ts +13 -12
  90. package/src/input/modes/handlers/shared.ts +1 -1
  91. package/src/input/modes/transitions.ts +34 -21
  92. package/src/input/modes/types.ts +48 -31
  93. package/src/ipc/manager-protocol.ts +44 -44
  94. package/src/ipc/protocol.ts +192 -166
  95. package/src/platform/play-sound.ts +184 -0
  96. package/src/platform/project-search.ts +8 -8
  97. package/src/platform/worktree-paths.ts +8 -6
  98. package/src/pty/assistant-question-extractor.ts +1 -1
  99. package/src/pty/assistant-status-detection-loop.ts +79 -55
  100. package/src/pty/assistant-status-detector.ts +40 -15
  101. package/src/pty/command-registry.ts +81 -1
  102. package/src/restart-terminal-manager.ts +1 -1
  103. package/src/services/ai-usage/provider.ts +9 -2
  104. package/src/services/ai-usage/spawn.ts +19 -6
  105. package/src/session-backend/bootstrap.ts +4 -4
  106. package/src/session-backend/local-session-backend.ts +78 -75
  107. package/src/session-backend/remote-session-backend.ts +64 -52
  108. package/src/session-backend/types.ts +43 -32
  109. package/src/settings/live.ts +90 -0
  110. package/src/settings/search.ts +43 -0
  111. package/src/settings/sections/about.ts +49 -0
  112. package/src/settings/sections/appearance.ts +54 -0
  113. package/src/settings/sections/automation.ts +105 -0
  114. package/src/settings/sections/commands.ts +72 -0
  115. package/src/settings/sections/editor.ts +56 -0
  116. package/src/settings/sections/experimental.ts +57 -0
  117. package/src/settings/sections/git.ts +112 -0
  118. package/src/settings/sections/index.ts +95 -0
  119. package/src/settings/sections/integrations.ts +23 -0
  120. package/src/settings/sections/layout.ts +72 -0
  121. package/src/settings/sections/notifications.ts +99 -0
  122. package/src/settings/sections/setup.ts +54 -0
  123. package/src/settings/sections/status-bar.ts +63 -0
  124. package/src/settings/settings-store.ts +207 -0
  125. package/src/settings/types.ts +108 -0
  126. package/src/snippets/run-shell-var.ts +48 -22
  127. package/src/state/actions.ts +342 -0
  128. package/src/state/app-store.ts +2 -1
  129. package/src/state/bars.ts +1 -1
  130. package/src/state/dispatch-ref.ts +1 -1
  131. package/src/state/layout-resize.ts +2 -2
  132. package/src/state/project-catalog.ts +275 -0
  133. package/src/state/project-data.ts +143 -0
  134. package/src/state/{session-persistence.ts → project-persistence.ts} +74 -68
  135. package/src/state/project-save.ts +46 -0
  136. package/src/state/project-workspaces.ts +392 -0
  137. package/src/state/reducers/auto-commit-state.ts +11 -10
  138. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  139. package/src/state/reducers/git-mode-state.ts +1 -1
  140. package/src/state/reducers/git-panel-state.ts +10 -6
  141. package/src/state/reducers/modal-state.ts +290 -681
  142. package/src/state/reducers/multi-repo-state.ts +3 -6
  143. package/src/state/reducers/project-state.ts +312 -0
  144. package/src/state/reducers/settings-state.ts +80 -0
  145. package/src/state/reducers/tab-state.ts +107 -60
  146. package/src/state/reducers/ui-state.ts +4 -3
  147. package/src/state/selectors.ts +49 -37
  148. package/src/state/store.ts +57 -24
  149. package/src/state/types.ts +244 -405
  150. package/src/state/validation.ts +14 -12
  151. package/src/terminal-manager/manager-client.ts +32 -32
  152. package/src/terminal-manager/terminal-manager.ts +24 -24
  153. package/src/ui/components/git/git-panel.tsx +2 -2
  154. package/src/ui/components/git/git-view.tsx +12 -12
  155. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  156. package/src/ui/components/git/pane/git-pane-widget.tsx +7 -7
  157. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  158. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  159. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  160. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  161. package/src/ui/components/layout/status-bar.tsx +6 -2
  162. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  163. package/src/ui/components/layout/top-tab-bar.tsx +46 -36
  164. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  165. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  166. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  167. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  168. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  169. package/src/ui/components/modals/shared/form.tsx +34 -22
  170. package/src/ui/components/modals/shared/picker.tsx +10 -10
  171. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  172. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  173. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  174. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  175. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  176. package/src/ui/components/primitives/input-field.tsx +7 -3
  177. package/src/ui/components/primitives/surface.tsx +3 -0
  178. package/src/ui/components/settings/row-value.tsx +74 -0
  179. package/src/ui/components/settings/settings-row.tsx +78 -0
  180. package/src/ui/components/settings/settings-view.tsx +169 -0
  181. package/src/ui/components/setup/setup-widget.tsx +152 -0
  182. package/src/ui/flash/build-labels.ts +27 -27
  183. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  184. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  185. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  186. package/src/ui/root.tsx +139 -121
  187. package/src/ui/status-bar-model.ts +48 -34
  188. package/src/ui/terminal-graphics/kitty.ts +28 -2
  189. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  190. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  191. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  192. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  193. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  194. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  195. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  196. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  197. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  198. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  199. package/src/ui/terminal-graphics/sprites.ts +246 -0
  200. package/src/ui/truncate.ts +7 -0
  201. package/src/ui/widgets/registry.tsx +6 -3
  202. package/src/ui/widgets/widget-context-menu.ts +26 -10
  203. package/src/update.ts +2 -2
  204. package/src/app-runtime/session-actions.ts +0 -187
  205. package/src/cli/client/workspace-resolver.ts +0 -110
  206. package/src/cli/commands/workspace/close.ts +0 -32
  207. package/src/cli/commands/workspace/show.ts +0 -32
  208. package/src/cli/commands/worktree/create.ts +0 -55
  209. package/src/cli/commands/worktree/list.ts +0 -68
  210. package/src/cli/commands/worktree/remove.ts +0 -81
  211. package/src/git/worktree-branch-poller.ts +0 -54
  212. package/src/git/worktree-divergence-poller.ts +0 -59
  213. package/src/state/reducers/session-state.ts +0 -262
  214. package/src/state/session-catalog.ts +0 -143
  215. package/src/state/session-worktrees.ts +0 -260
  216. package/src/state/workspace-save.ts +0 -43
  217. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  218. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  219. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  220. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -1,43 +0,0 @@
1
- import type { AppState, SessionRecord } from './types'
2
-
3
- import { loadConfig, saveConfig } from '../config'
4
- import { saveSessionCatalog } from './session-catalog'
5
- import { serializeWorkspace } from './session-persistence'
6
-
7
- export function buildSessionsWithCurrentSnapshot(
8
- sessions: SessionRecord[],
9
- currentSessionId: string | null,
10
- state: AppState
11
- ): SessionRecord[] {
12
- return sessions.map((session) =>
13
- session.id === currentSessionId
14
- ? {
15
- ...session,
16
- updatedAt: new Date().toISOString(),
17
- workspaceSnapshot: serializeWorkspace(state),
18
- }
19
- : session
20
- )
21
- }
22
-
23
- export function saveCurrentWorkspace(state: AppState): void {
24
- saveConfig({
25
- ...loadConfig(),
26
- bars: state.bars,
27
- customCommands: state.customCommands,
28
- gitPane: {
29
- diffModeRatio: state.gitPane.diffModeRatio,
30
- fileListMode: state.gitPane.fileListMode,
31
- },
32
- sessionBarVisible: state.sessionBar.visible,
33
- // Legacy mirror of the left bar: lets an older build (and the workspace
34
- // snapshot schema) still find a sidebar after a downgrade.
35
- sidebar: {
36
- visible: state.bars.left.visible,
37
- width: state.bars.left.width,
38
- },
39
- })
40
- saveSessionCatalog(
41
- buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
42
- )
43
- }
@@ -1,30 +0,0 @@
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
- }
@@ -1,418 +0,0 @@
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 } 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 { useBaseTheme, useTheme } from '../../../theme'
19
- import { FlashLabelBadge } from '../../flash/flash-label-badge'
20
- import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
21
- import { useSidebarAutoScroll } from './use-sidebar-auto-scroll'
22
- import { WorktreeRow } from './worktree-row'
23
-
24
- interface WorkspaceListProps {
25
- contentWidth: number
26
- }
27
-
28
- const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
29
-
30
- function arraysEqual(a: string[], b: string[]): boolean {
31
- if (a.length !== b.length) return false
32
- for (let i = 0; i < a.length; i++) {
33
- if (a[i] !== b[i]) return false
34
- }
35
- return true
36
- }
37
-
38
- function truncate(label: string, max: number): string {
39
- if (max <= 0) return ''
40
- if (label.length <= max) return label
41
- if (max === 1) return '…'
42
- return `${label.slice(0, max - 1)}…`
43
- }
44
-
45
- export function WorkspaceList({ contentWidth }: WorkspaceListProps) {
46
- const t = useTheme()
47
- const sessions = useAppStore((s) => s.sessions)
48
- const currentSessionId = useAppStore((s) => s.currentSessionId)
49
- const statusMap = useAppStore((s) => s.sessionStatuses)
50
-
51
- const [draggingId, setDraggingId] = useState<string | null>(null)
52
- const [dragOrder, setDragOrder] = useState<string[] | null>(null)
53
- const lastSwapWithRef = useRef<string | null>(null)
54
- const rowRefs = useRef(new Map<string, BoxRenderable>())
55
- const scrollRef = useRef<ScrollBoxRenderable | null>(null)
56
-
57
- const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
58
- const baselineOrder = useMemo(() => ordered.map((s) => s.id), [ordered])
59
-
60
- const currentSession = useMemo(
61
- () =>
62
- currentSessionId != null && currentSessionId !== ''
63
- ? sessions.find((s) => s.id === currentSessionId)
64
- : undefined,
65
- [currentSessionId, sessions]
66
- )
67
- // The active row can be either a worktree row OR the workspace row
68
- // (when the primary worktree is active). Both must scroll into view —
69
- // otherwise the cursor visually "disappears" off-screen when crossing
70
- // a workspace boundary on a key press.
71
- const currentWorktrees = currentSession?.worktrees ?? []
72
- const currentPrimary = currentWorktrees.find((w) => w.source === 'primary') ?? currentWorktrees[0]
73
- const rawActiveWorktreeId = currentSession?.activeWorktreeId
74
- const activeOnNonPrimary =
75
- rawActiveWorktreeId != null &&
76
- rawActiveWorktreeId !== '' &&
77
- rawActiveWorktreeId !== currentPrimary?.id
78
- let activeRowId: string | null = null
79
- if (activeOnNonPrimary) {
80
- activeRowId = `sidebar-wt-${rawActiveWorktreeId}`
81
- } else if (currentSessionId != null && currentSessionId !== '') {
82
- activeRowId = `sidebar-ws-${currentSessionId}`
83
- }
84
-
85
- useSidebarAutoScroll({
86
- activeRowId,
87
- scrollRef,
88
- visible: true,
89
- })
90
-
91
- const setRowRef = useCallback((id: string, ref: BoxRenderable | null): void => {
92
- if (ref) rowRefs.current.set(id, ref)
93
- else rowRefs.current.delete(id)
94
- }, [])
95
-
96
- const findRowAtY = useCallback((y: number): string | null => {
97
- for (const [id, ref] of rowRefs.current) {
98
- if (y >= ref.y && y < ref.y + ref.height) return id
99
- }
100
- return null
101
- }, [])
102
-
103
- const handleRowDragStart = useCallback(
104
- (id: string) => {
105
- setDraggingId(id)
106
- setDragOrder(baselineOrder)
107
- lastSwapWithRef.current = null
108
- },
109
- [baselineOrder]
110
- )
111
-
112
- const handleRowDrag = useCallback(
113
- (event: OtuiMouseEvent) => {
114
- if (!(draggingId != null && draggingId !== '')) return
115
- const hit = findRowAtY(event.y)
116
- if (hit === null) {
117
- lastSwapWithRef.current = null
118
- return
119
- }
120
- if (hit === draggingId) {
121
- lastSwapWithRef.current = null
122
- return
123
- }
124
- if (hit === lastSwapWithRef.current) return
125
- setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
126
- lastSwapWithRef.current = hit
127
- },
128
- [draggingId, findRowAtY]
129
- )
130
-
131
- const commitDrop = useCallback(() => {
132
- const source = draggingId
133
- const finalOrder = dragOrder
134
- setDraggingId(null)
135
- setDragOrder(null)
136
- lastSwapWithRef.current = null
137
-
138
- if (source == null || source === '' || !finalOrder) return
139
-
140
- const changed = !arraysEqual(finalOrder, baselineOrder)
141
- if (changed) {
142
- dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
143
- return
144
- }
145
-
146
- const idx = baselineOrder.indexOf(source)
147
- if (idx >= 0) {
148
- // The workspace row visually anchors the session's primary worktree
149
- // (its branch line shows the primary's branch). Clicking it should
150
- // land on the primary — same semantics as j/k cycling onto a workspace
151
- // item — instead of preserving whatever non-primary worktree happened
152
- // to be active last time we left this session.
153
- const sourceSession = ordered.find((s) => s.id === source)
154
- const sourceWorktrees = sourceSession?.worktrees ?? []
155
- const sourcePrimaryId = (
156
- sourceWorktrees.find((w) => w.source === 'primary') ?? sourceWorktrees[0]
157
- )?.id
158
- runSideEffectGlobal({
159
- index: idx + 1,
160
- type: 'switch-session-by-index',
161
- worktreeId: sourcePrimaryId,
162
- })
163
- }
164
- }, [baselineOrder, dragOrder, draggingId, ordered])
165
-
166
- const cancelDrag = useCallback(() => {
167
- setDraggingId(null)
168
- setDragOrder(null)
169
- lastSwapWithRef.current = null
170
- }, [])
171
-
172
- const handleNewSession = useCallback((e: OtuiMouseEvent) => {
173
- e.stopPropagation()
174
- e.preventDefault()
175
- dispatchGlobal({ returnToSessionPicker: false, type: 'open-create-session-modal' })
176
- }, [])
177
-
178
- const visibleSessions =
179
- dragOrder !== null
180
- ? dragOrder
181
- .map((id) => ordered.find((s) => s.id === id))
182
- .filter((s): s is SessionRecord => !!s)
183
- : ordered
184
-
185
- return (
186
- <box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
187
- <scrollbox
188
- ref={scrollRef}
189
- scrollY
190
- flexGrow={1}
191
- flexShrink={1}
192
- contentOptions={COLUMN_CONTENT_OPTIONS}
193
- >
194
- {(() => {
195
- // Build a single flat list of items — workspace rows interleaved
196
- // with their non-primary worktrees. One map, one React keypath per
197
- // visible row; transitions are a single atomic reconciliation.
198
- const rows: ReactNode[] = []
199
- for (const [visibleIdx, session] of visibleSessions.entries()) {
200
- const sessionIndex = baselineOrder.indexOf(session.id) + 1
201
- const isCurrentSession = session.id === currentSessionId
202
- const worktrees = session.worktrees ?? []
203
- const primaryWorktree = worktrees.find((w) => w.source === 'primary') ?? worktrees[0]
204
- const extraWorktrees = worktrees.filter((w) => w.id !== primaryWorktree?.id)
205
- const workspaceIsActiveItem =
206
- isCurrentSession &&
207
- (session.activeWorktreeId == null ||
208
- session.activeWorktreeId === '' ||
209
- session.activeWorktreeId === primaryWorktree?.id)
210
- rows.push(
211
- <WorkspaceRow
212
- key={`ws:${session.id}`}
213
- session={session}
214
- isActiveItem={workspaceIsActiveItem}
215
- inCurrentGroup={isCurrentSession}
216
- primaryWorktree={primaryWorktree}
217
- hasExtraWorktrees={extraWorktrees.length > 0}
218
- status={statusMap[session.id] ?? IDLE_SESSION_STATUS}
219
- dragging={draggingId === session.id}
220
- contentWidth={contentWidth}
221
- marginTop={visibleIdx > 0 ? 1 : 0}
222
- setRowRef={setRowRef}
223
- onDragStart={handleRowDragStart}
224
- onDrag={handleRowDrag}
225
- onDrop={commitDrop}
226
- onDragCancel={cancelDrag}
227
- />
228
- )
229
- for (const [wtIdx, worktree] of extraWorktrees.entries()) {
230
- rows.push(
231
- <WorktreeRow
232
- key={`wt:${worktree.id}`}
233
- session={session}
234
- worktree={worktree}
235
- sessionIndex={sessionIndex}
236
- isActiveItem={isCurrentSession && worktree.id === session.activeWorktreeId}
237
- inCurrentGroup={isCurrentSession}
238
- isLast={wtIdx === extraWorktrees.length - 1}
239
- />
240
- )
241
- }
242
- }
243
- return rows
244
- })()}
245
- </scrollbox>
246
- <box
247
- flexDirection="row"
248
- flexShrink={0}
249
- marginTop={1}
250
- backgroundColor={t.backgroundPanel}
251
- justifyContent="center"
252
- onMouseDown={handleNewSession}
253
- >
254
- <text fg={t.text} selectable={false}>
255
- + New workspace
256
- </text>
257
- </box>
258
- </box>
259
- )
260
- }
261
-
262
- interface WorkspaceRowProps {
263
- session: SessionRecord
264
- /** True when this row is the active cursor item (workspace's primary active). */
265
- isActiveItem: boolean
266
- /** True when this row belongs to the current workspace (selection scope). */
267
- inCurrentGroup: boolean
268
- /** The session's primary worktree — its git branch is shown as the workspace's anchor identity. */
269
- primaryWorktree: WorktreeRecord | undefined
270
- /** True when at least one non-primary worktree follows — used to draw the tree continuator. */
271
- hasExtraWorktrees: boolean
272
- status: SessionStatus
273
- dragging: boolean
274
- contentWidth: number
275
- /** Vertical spacing above this row — used to separate workspace blocks. */
276
- marginTop: number
277
- setRowRef: (id: string, ref: BoxRenderable | null) => void
278
- onDragStart: (id: string) => void
279
- onDrag: (event: OtuiMouseEvent) => void
280
- onDrop: () => void
281
- onDragCancel: () => void
282
- }
283
-
284
- const WorkspaceRow = memo(function WorkspaceRow({
285
- contentWidth,
286
- dragging,
287
- hasExtraWorktrees,
288
- inCurrentGroup,
289
- isActiveItem,
290
- marginTop,
291
- onDrag,
292
- onDragCancel,
293
- onDragStart,
294
- onDrop,
295
- primaryWorktree,
296
- session,
297
- setRowRef,
298
- status,
299
- }: WorkspaceRowProps) {
300
- const t = useTheme()
301
- // Selection highlight must stay opaque in transparent mode — otherwise the
302
- // cursor row visually disappears against the see-through chrome.
303
- const base = useBaseTheme()
304
- const showSpinner = status.working
305
- const showWaiting = status.waiting
306
- const spinner = useBusySpinner(showSpinner)
307
- let bgColor: string | undefined
308
- if (dragging || isActiveItem) {
309
- bgColor = base.backgroundElement
310
- } else if (inCurrentGroup) {
311
- bgColor = base.backgroundPanel
312
- }
313
- const workingColor = t.primary
314
- const waitingColor = t.warning
315
- const divergence = useAppStore((s) =>
316
- primaryWorktree ? s.worktreeDivergence[primaryWorktree.id] : undefined
317
- )
318
-
319
- const handleRef = useCallback(
320
- (r: BoxRenderable | null) => setRowRef(session.id, r),
321
- [setRowRef, session.id]
322
- )
323
- const handleMouseDown = useCallback(
324
- (e: OtuiMouseEvent) => {
325
- e.preventDefault()
326
- e.stopPropagation()
327
- onDragStart(session.id)
328
- },
329
- [onDragStart, session.id]
330
- )
331
- const handleMouseUp = useCallback(
332
- (e: OtuiMouseEvent) => {
333
- e.preventDefault()
334
- onDrop()
335
- },
336
- [onDrop]
337
- )
338
- const rightClickMenu = useMemo<[string, () => void][]>(
339
- () => [
340
- [
341
- 'Rename workspace',
342
- () =>
343
- dispatchGlobal({
344
- initialName: session.name,
345
- returnToSessionPicker: false,
346
- sessionTargetId: session.id,
347
- type: 'open-session-name-modal',
348
- }),
349
- ],
350
- [
351
- 'Delete workspace',
352
- () => runSideEffectGlobal({ sessionId: session.id, type: 'delete-session' }),
353
- ],
354
- ],
355
- [session.id, session.name]
356
- )
357
-
358
- // Neutral muted marker; working/waiting overrides it. Keeping a glyph
359
- // in the slot avoids name shifts when state indicators come and go.
360
- let leadingGlyph = '•'
361
- let leadingColor = t.textMuted
362
- if (showWaiting) {
363
- leadingGlyph = '?'
364
- leadingColor = waitingColor
365
- } else if (showSpinner) {
366
- leadingGlyph = spinner
367
- leadingColor = workingColor
368
- }
369
-
370
- const branchText = primaryWorktree?.branch ?? ''
371
- const divergenceText = formatDivergence(divergence)
372
- const showBranch = branchText !== ''
373
- const nameLabel = truncate(session.name, Math.max(0, contentWidth - 4))
374
- const branchLabel = truncate(
375
- branchText,
376
- Math.max(0, contentWidth - 5 - (divergenceText.length + 1))
377
- )
378
-
379
- return (
380
- <ContextMenuBox
381
- ref={handleRef}
382
- id={`sidebar-ws-${session.id}`}
383
- flexDirection="column"
384
- flexShrink={0}
385
- marginTop={marginTop}
386
- paddingLeft={1}
387
- paddingRight={1}
388
- backgroundColor={bgColor}
389
- rightClickMenu={rightClickMenu}
390
- onMouseDown={handleMouseDown}
391
- onMouseDrag={onDrag}
392
- onMouseUp={handleMouseUp}
393
- onMouseDragEnd={onDragCancel}
394
- >
395
- <box flexDirection="row" alignItems="center">
396
- <text fg={leadingColor} selectable={false} wrapMode="none">
397
- {leadingGlyph}
398
- </text>
399
- <text fg={t.text} selectable={false} wrapMode="none">
400
- {' '}
401
- </text>
402
- <FlashLabelBadge rowKey={`ws:${session.id}`} />
403
- <text fg={t.text} selectable={false} wrapMode="none">
404
- {nameLabel}
405
- </text>
406
- </box>
407
- {showBranch ? (
408
- <box flexDirection="row">
409
- <text fg={t.textMuted} selectable={false} wrapMode="none">
410
- {hasExtraWorktrees ? '│ ' : ' '}
411
- {'\u{e702}'} {branchLabel}
412
- {divergenceText !== '' ? ` ${divergenceText}` : ''}
413
- </text>
414
- </box>
415
- ) : null}
416
- </ContextMenuBox>
417
- )
418
- })
@@ -1,149 +0,0 @@
1
- import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
2
-
3
- import { memo, useCallback, useMemo } from 'react'
4
-
5
- import type { SessionRecord, WorktreeRecord } from '../../../../state/types'
6
-
7
- import { useAppStore } from '../../../../state/app-store'
8
- import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
9
- import { formatDivergence } from '../../../../state/session-worktrees'
10
- import { useBaseTheme, useTheme } from '../../../theme'
11
- import { FlashLabelBadge } from '../../flash/flash-label-badge'
12
- import { ContextMenuBox } from '../../overlays/context-menu/context-menu-box'
13
-
14
- interface WorktreeRowProps {
15
- session: SessionRecord
16
- worktree: WorktreeRecord
17
- /** 1-based index of the workspace in the visible order (for switch-session-by-index). */
18
- sessionIndex: number
19
- /** True when this row is the active cursor item. */
20
- isActiveItem: boolean
21
- /** True when this row's workspace is the current session (selection scope). */
22
- inCurrentGroup: boolean
23
- /** True when this is the last non-primary worktree of its workspace. Drives └─ vs ├─. */
24
- isLast: boolean
25
- }
26
-
27
- export const WorktreeRow = memo(function WorktreeRow({
28
- inCurrentGroup,
29
- isActiveItem,
30
- isLast,
31
- session,
32
- sessionIndex,
33
- worktree,
34
- }: WorktreeRowProps) {
35
- const t = useTheme()
36
- // Selection highlight must stay opaque in transparent mode.
37
- const base = useBaseTheme()
38
- const currentSessionId = useAppStore((s) => s.currentSessionId)
39
- const isCurrentSession = session.id === currentSessionId
40
- const divergence = useAppStore((s) => s.worktreeDivergence[worktree.id])
41
-
42
- const handleMouseDown = useCallback(
43
- (event: OtuiMouseEvent) => {
44
- if (event.button !== 0) return
45
- event.preventDefault()
46
- event.stopPropagation()
47
- if (isCurrentSession) {
48
- dispatchGlobal({
49
- sessionId: session.id,
50
- type: 'set-active-worktree',
51
- worktreeId: worktree.id,
52
- })
53
- return
54
- }
55
- // Cross-workspace click: send the worktree id along with the switch so
56
- // the side effect can apply it atomically. Splitting it into a separate
57
- // dispatch+switch leaves a window where subscribers/re-renders can
58
- // re-assert the target session's last-persisted worktree.
59
- runSideEffectGlobal({
60
- index: sessionIndex,
61
- type: 'switch-session-by-index',
62
- worktreeId: worktree.id,
63
- })
64
- },
65
- [isCurrentSession, session.id, sessionIndex, worktree.id]
66
- )
67
-
68
- const rightClickMenu = useMemo<[string, () => void][] | undefined>(() => {
69
- const entries: [string, () => void][] = [
70
- [
71
- 'Rename',
72
- () =>
73
- dispatchGlobal({
74
- initialName: worktree.name,
75
- sessionId: session.id,
76
- type: 'open-rename-worktree-modal',
77
- worktreeId: worktree.id,
78
- }),
79
- ],
80
- ]
81
- if (worktree.source !== 'primary') {
82
- entries.push([
83
- 'Remove worktree',
84
- () =>
85
- // Always confirm first. Confirming routes through the full delete side
86
- // effect (closes the worktree's tabs, disposes their PTYs, prunes the
87
- // snapshot, removes the git worktree). closeTabs (not force) cleans up
88
- // the tabs while keeping the non-force `git worktree remove`, so
89
- // uncommitted work in a temp worktree is still protected — a dirty
90
- // worktree re-prompts for an explicit force-delete.
91
- dispatchGlobal({
92
- closeTabs: true,
93
- force: false,
94
- reason: 'Its assistant tabs will be closed and the worktree removed.',
95
- sessionId: session.id,
96
- type: 'open-worktree-delete-confirm',
97
- worktreeId: worktree.id,
98
- worktreeLabel: worktree.branch ?? worktree.name,
99
- }),
100
- ])
101
- }
102
- return entries
103
- }, [session.id, worktree.branch, worktree.id, worktree.name, worktree.source])
104
-
105
- let bgColor: string | undefined
106
- if (isActiveItem) {
107
- bgColor = base.backgroundElement
108
- } else if (inCurrentGroup) {
109
- bgColor = base.backgroundPanel
110
- }
111
-
112
- const connector = isLast ? '└─' : '├─'
113
- const branchCont = isLast ? ' ' : '│ '
114
- const branchText = worktree.branch ?? ''
115
- const showBranch = branchText !== ''
116
- const divergenceText = formatDivergence(divergence)
117
-
118
- return (
119
- <ContextMenuBox
120
- id={`sidebar-wt-${worktree.id}`}
121
- flexDirection="column"
122
- flexShrink={0}
123
- paddingLeft={1}
124
- paddingRight={1}
125
- backgroundColor={bgColor}
126
- rightClickMenu={rightClickMenu}
127
- onMouseDown={handleMouseDown}
128
- >
129
- <box flexDirection="row" alignItems="center">
130
- <text fg={t.textMuted} selectable={false} wrapMode="none">
131
- {connector}{' '}
132
- </text>
133
- <FlashLabelBadge rowKey={`wt:${worktree.id}`} />
134
- <text fg={t.text} selectable={false} wrapMode="none">
135
- {worktree.name}
136
- </text>
137
- </box>
138
- {showBranch ? (
139
- <box flexDirection="row">
140
- <text fg={t.textMuted} selectable={false} wrapMode="none">
141
- {branchCont}
142
- {'\u{e702}'} {branchText}
143
- {divergenceText !== '' ? ` ${divergenceText}` : ''}
144
- </text>
145
- </box>
146
- ) : null}
147
- </ContextMenuBox>
148
- )
149
- })