@brimveyn/aimux 1.20.4 → 1.22.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.
Files changed (233) 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 +305 -1589
  17. package/src/app-runtime/snippet-actions.ts +3 -2
  18. package/src/app-runtime/split-drag-controller.ts +4 -8
  19. package/src/app-runtime/tab-actions.ts +218 -0
  20. package/src/app-runtime/tab-runtime-timeouts.ts +1 -1
  21. package/src/app-runtime/use-auto-commit-driver.ts +14 -13
  22. package/src/app-runtime/use-backend-runtime.ts +12 -11
  23. package/src/app-runtime/use-directory-search.ts +5 -4
  24. package/src/app-runtime/use-mouse-handlers.ts +28 -42
  25. package/src/app-runtime/{use-workspace-autosave.ts → use-project-autosave.ts} +4 -4
  26. package/src/app-runtime/use-renderer-bindings.ts +2 -1
  27. package/src/app-runtime/use-setup-runner.ts +96 -0
  28. package/src/app-runtime/use-terminal-resize.ts +28 -33
  29. package/src/app-runtime/workspace-actions.ts +366 -0
  30. package/src/app-runtime/workspace-activity.ts +138 -0
  31. package/src/app-runtime/workspace-naming.ts +90 -0
  32. package/src/app.tsx +102 -85
  33. package/src/assets/sounds/bell.wav +0 -0
  34. package/src/assets/sounds/ding.wav +0 -0
  35. package/src/assets/sounds/plane.wav +0 -0
  36. package/src/cli/client/daemon-client.ts +2 -2
  37. package/src/cli/client/project-resolver.ts +112 -0
  38. package/src/cli/commands/project/close.ts +32 -0
  39. package/src/cli/commands/project/create.ts +93 -0
  40. package/src/cli/commands/project/list.ts +25 -0
  41. package/src/cli/commands/project/show.ts +32 -0
  42. package/src/cli/commands/{workspace → project}/switch.ts +19 -19
  43. package/src/cli/commands/tab/await.ts +2 -2
  44. package/src/cli/commands/tab/close.ts +3 -3
  45. package/src/cli/commands/tab/create.ts +74 -73
  46. package/src/cli/commands/tab/focus.ts +3 -3
  47. package/src/cli/commands/tab/list.ts +6 -6
  48. package/src/cli/commands/tab/run.ts +2 -2
  49. package/src/cli/commands/tab/send.ts +2 -2
  50. package/src/cli/commands/tab/snapshot.ts +2 -2
  51. package/src/cli/commands/tab/tail.ts +2 -2
  52. package/src/cli/commands/tab/wait.ts +2 -2
  53. package/src/cli/commands/worker/await.ts +3 -3
  54. package/src/cli/commands/worker/doctor.ts +30 -30
  55. package/src/cli/commands/worker/list.ts +15 -15
  56. package/src/cli/commands/worker/prompt.ts +3 -3
  57. package/src/cli/commands/worker/run.ts +23 -23
  58. package/src/cli/commands/worker/shared.ts +53 -59
  59. package/src/cli/commands/worker/stop.ts +29 -29
  60. package/src/cli/commands/worker/submit.ts +3 -3
  61. package/src/cli/commands/{worktree → workspace}/create-core.ts +26 -26
  62. package/src/cli/commands/workspace/create.ts +30 -68
  63. package/src/cli/commands/workspace/list.ts +52 -9
  64. package/src/cli/commands/workspace/remove.ts +81 -0
  65. package/src/cli/completion/plan.ts +2 -2
  66. package/src/cli/completion/sources.ts +18 -18
  67. package/src/cli/context.ts +13 -13
  68. package/src/cli/flags.ts +30 -10
  69. package/src/cli/index.ts +26 -16
  70. package/src/cli/output.ts +1 -1
  71. package/src/cli/registry.ts +12 -12
  72. package/src/config/loader.ts +16 -5
  73. package/src/config.ts +149 -101
  74. package/src/daemon/catalog-writer.ts +67 -67
  75. package/src/daemon/daemon.ts +205 -201
  76. package/src/daemon/session-manager.ts +39 -39
  77. package/src/daemon/session-registry.ts +14 -14
  78. package/src/git/divergence.ts +33 -2
  79. package/src/git/git-poller.ts +1 -1
  80. package/src/git/git-status.ts +2 -2
  81. package/src/git/{move-worktree.ts → move-workspace.ts} +5 -5
  82. package/src/git/pr-merge.ts +64 -0
  83. package/src/git/pr-status-poller.ts +57 -0
  84. package/src/git/pr-status.ts +227 -0
  85. package/src/git/repo-discovery.ts +1 -1
  86. package/src/git/use-repo-discovery.ts +1 -1
  87. package/src/git/workspace-branch-poller.ts +54 -0
  88. package/src/git/workspace-divergence-poller.ts +65 -0
  89. package/src/git/worktree.ts +29 -0
  90. package/src/index.tsx +11 -6
  91. package/src/input/keymap/help-entries.ts +7 -5
  92. package/src/input/modes/bridge.ts +13 -12
  93. package/src/input/modes/handlers/shared.ts +1 -1
  94. package/src/input/modes/transitions.ts +34 -21
  95. package/src/input/modes/types.ts +48 -31
  96. package/src/ipc/manager-protocol.ts +44 -44
  97. package/src/ipc/protocol.ts +192 -166
  98. package/src/platform/open-url.ts +39 -0
  99. package/src/platform/play-sound.ts +184 -0
  100. package/src/platform/project-search.ts +8 -8
  101. package/src/platform/worktree-paths.ts +8 -6
  102. package/src/pty/assistant-question-extractor.ts +1 -1
  103. package/src/pty/assistant-status-detection-loop.ts +79 -55
  104. package/src/pty/assistant-status-detector.ts +40 -15
  105. package/src/pty/command-registry.ts +81 -1
  106. package/src/restart-terminal-manager.ts +1 -1
  107. package/src/services/ai-usage/provider.ts +9 -2
  108. package/src/services/ai-usage/spawn.ts +3 -1
  109. package/src/session-backend/bootstrap.ts +4 -4
  110. package/src/session-backend/local-session-backend.ts +78 -75
  111. package/src/session-backend/remote-session-backend.ts +64 -52
  112. package/src/session-backend/types.ts +43 -32
  113. package/src/settings/live.ts +90 -0
  114. package/src/settings/search.ts +43 -0
  115. package/src/settings/sections/about.ts +49 -0
  116. package/src/settings/sections/appearance.ts +54 -0
  117. package/src/settings/sections/automation.ts +105 -0
  118. package/src/settings/sections/commands.ts +72 -0
  119. package/src/settings/sections/editor.ts +56 -0
  120. package/src/settings/sections/experimental.ts +57 -0
  121. package/src/settings/sections/git.ts +112 -0
  122. package/src/settings/sections/index.ts +95 -0
  123. package/src/settings/sections/integrations.ts +23 -0
  124. package/src/settings/sections/layout.ts +72 -0
  125. package/src/settings/sections/notifications.ts +99 -0
  126. package/src/settings/sections/setup.ts +54 -0
  127. package/src/settings/sections/status-bar.ts +63 -0
  128. package/src/settings/settings-store.ts +207 -0
  129. package/src/settings/types.ts +108 -0
  130. package/src/snippets/run-shell-var.ts +48 -22
  131. package/src/state/actions.ts +342 -0
  132. package/src/state/app-store.ts +2 -1
  133. package/src/state/bars.ts +75 -0
  134. package/src/state/dispatch-ref.ts +1 -1
  135. package/src/state/git-pane-sizing.ts +0 -9
  136. package/src/state/layout-resize.ts +2 -2
  137. package/src/state/pr-status-store.ts +39 -0
  138. package/src/state/project-catalog.ts +275 -0
  139. package/src/state/project-data.ts +143 -0
  140. package/src/state/{session-persistence.ts → project-persistence.ts} +77 -73
  141. package/src/state/project-save.ts +46 -0
  142. package/src/state/project-workspaces.ts +392 -0
  143. package/src/state/reducers/auto-commit-state.ts +11 -10
  144. package/src/state/reducers/git-commit-modal-state.ts +122 -0
  145. package/src/state/reducers/git-mode-state.ts +1 -1
  146. package/src/state/reducers/git-panel-state.ts +10 -53
  147. package/src/state/reducers/modal-state.ts +290 -681
  148. package/src/state/reducers/multi-repo-state.ts +3 -6
  149. package/src/state/reducers/project-state.ts +312 -0
  150. package/src/state/reducers/settings-state.ts +80 -0
  151. package/src/state/reducers/tab-state.ts +107 -60
  152. package/src/state/reducers/ui-state.ts +86 -15
  153. package/src/state/selectors.ts +49 -37
  154. package/src/state/store.ts +83 -57
  155. package/src/state/types.ts +261 -417
  156. package/src/state/validation.ts +14 -12
  157. package/src/terminal-manager/manager-client.ts +32 -32
  158. package/src/terminal-manager/terminal-manager.ts +24 -24
  159. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  160. package/src/ui/components/git/git-panel.tsx +2 -2
  161. package/src/ui/components/git/git-view.tsx +12 -12
  162. package/src/ui/components/git/image-diff/image-diff-view.tsx +1 -1
  163. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  164. package/src/ui/components/git/pane/git-pane-widget.tsx +37 -23
  165. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  166. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  167. package/src/ui/components/layout/bar.tsx +191 -0
  168. package/src/ui/components/layout/sidebar/project-list.tsx +444 -0
  169. package/src/ui/components/layout/sidebar/tab-item.tsx +27 -12
  170. package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +1 -1
  171. package/src/ui/components/layout/sidebar/workspace-row.tsx +253 -0
  172. package/src/ui/components/layout/status-bar.tsx +6 -2
  173. package/src/ui/components/layout/terminal-pane.tsx +34 -29
  174. package/src/ui/components/layout/top-tab-bar.tsx +48 -37
  175. package/src/ui/components/modals/git/git-commit-modal.tsx +8 -8
  176. package/src/ui/components/modals/{sessions/create-session-modal.tsx → projects/create-project-modal.tsx} +12 -12
  177. package/src/ui/components/modals/{sessions/session-name-modal.tsx → projects/project-name-modal.tsx} +2 -2
  178. package/src/ui/components/modals/{sessions/session-picker-modal.tsx → projects/project-picker-modal.tsx} +33 -35
  179. package/src/ui/components/modals/settings/settings-search-modal.tsx +70 -0
  180. package/src/ui/components/modals/shared/form.tsx +34 -22
  181. package/src/ui/components/modals/shared/picker.tsx +10 -10
  182. package/src/ui/components/modals/shared/{worktree-delete-confirm.tsx → workspace-delete-confirm.tsx} +9 -9
  183. package/src/ui/components/modals/tabs/new-tab-modal.tsx +30 -324
  184. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +101 -0
  185. package/src/ui/components/modals/{worktree/worktree-move-confirm-modal.tsx → workspace/workspace-move-confirm-modal.tsx} +5 -5
  186. package/src/ui/components/modals/{worktree/worktree-move-modal.tsx → workspace/workspace-move-modal.tsx} +29 -27
  187. package/src/ui/components/primitives/input-field.tsx +7 -3
  188. package/src/ui/components/primitives/surface.tsx +3 -0
  189. package/src/ui/components/settings/row-value.tsx +74 -0
  190. package/src/ui/components/settings/settings-row.tsx +78 -0
  191. package/src/ui/components/settings/settings-view.tsx +169 -0
  192. package/src/ui/components/setup/setup-widget.tsx +152 -0
  193. package/src/ui/flash/build-labels.ts +27 -27
  194. package/src/ui/hooks/use-activity-sprite.ts +73 -0
  195. package/src/ui/hooks/use-scroll-active-into-view.ts +26 -0
  196. package/src/ui/{session-ordering.ts → project-ordering.ts} +4 -4
  197. package/src/ui/root.tsx +164 -230
  198. package/src/ui/status-bar-model.ts +48 -34
  199. package/src/ui/terminal-graphics/kitty.ts +28 -2
  200. package/src/ui/terminal-graphics/sprites/done@330/0.png +0 -0
  201. package/src/ui/terminal-graphics/sprites/done@330/1.png +0 -0
  202. package/src/ui/terminal-graphics/sprites/idle@1000/0.png +0 -0
  203. package/src/ui/terminal-graphics/sprites/idle@1000/1.png +0 -0
  204. package/src/ui/terminal-graphics/sprites/waiting@170/0.png +0 -0
  205. package/src/ui/terminal-graphics/sprites/waiting@170/1.png +0 -0
  206. package/src/ui/terminal-graphics/sprites/working@150/0.png +0 -0
  207. package/src/ui/terminal-graphics/sprites/working@150/1.png +0 -0
  208. package/src/ui/terminal-graphics/sprites/working@150/2.png +0 -0
  209. package/src/ui/terminal-graphics/sprites/working@150/3.png +0 -0
  210. package/src/ui/terminal-graphics/sprites.ts +246 -0
  211. package/src/ui/truncate.ts +7 -0
  212. package/src/ui/widgets/registry.tsx +21 -0
  213. package/src/ui/widgets/widget-context-menu.ts +85 -0
  214. package/src/update.ts +2 -2
  215. package/src/app-runtime/session-actions.ts +0 -187
  216. package/src/cli/client/workspace-resolver.ts +0 -110
  217. package/src/cli/commands/workspace/close.ts +0 -32
  218. package/src/cli/commands/workspace/show.ts +0 -32
  219. package/src/cli/commands/worktree/create.ts +0 -55
  220. package/src/cli/commands/worktree/list.ts +0 -68
  221. package/src/cli/commands/worktree/remove.ts +0 -81
  222. package/src/git/worktree-branch-poller.ts +0 -54
  223. package/src/git/worktree-divergence-poller.ts +0 -59
  224. package/src/state/reducers/session-state.ts +0 -262
  225. package/src/state/session-catalog.ts +0 -143
  226. package/src/state/session-worktrees.ts +0 -260
  227. package/src/state/workspace-save.ts +0 -45
  228. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  229. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
  230. package/src/ui/components/layout/sidebar/use-top-tab-bar-auto-scroll.ts +0 -30
  231. package/src/ui/components/layout/sidebar/workspace-list.tsx +0 -418
  232. package/src/ui/components/layout/sidebar/worktree-row.tsx +0 -149
  233. /package/src/ui/{components/git/image-diff → terminal-graphics}/dimensions.ts +0 -0
@@ -0,0 +1,96 @@
1
+ import { useEffect, useRef } from 'react'
2
+
3
+ import type { AppState, ProjectRecord, TabSession, WorkspaceRecord } from '../state/types'
4
+ import type { SideEffectContext } from './side-effect-context'
5
+
6
+ import { hasSetupScript } from '../state/project-data'
7
+ import { findSetupTab, runSetup } from './setup-actions'
8
+
9
+ export interface SetupCandidate {
10
+ projectId: string
11
+ workspace: WorkspaceRecord
12
+ }
13
+
14
+ /**
15
+ * Which workspaces should have their setup script run right now.
16
+ *
17
+ * The trigger is "a workspace id this session has not seen before", not "a
18
+ * workspace became active". Activation is the wrong signal: `j`/`k` cycles
19
+ * workspaces, so an activation-based rule would fire the script in every
20
+ * workspace the user scrolls past. Watching the id set also means one rule
21
+ * covers both creation paths — the TUI's `<C-p>` and `aimux workspace create`
22
+ * arriving over the daemon — instead of a hook per call site, which is how the
23
+ * CLI path ends up silently skipping setup.
24
+ *
25
+ * Mutates `seenByProject`: every id examined is marked seen, so a workspace is
26
+ * only ever a candidate once.
27
+ */
28
+ export function collectSetupCandidates(
29
+ projects: ProjectRecord[],
30
+ tabs: TabSession[],
31
+ seenByProject: Map<string, Set<string>>,
32
+ hasScript: (projectId: string) => boolean
33
+ ): SetupCandidate[] {
34
+ const candidates: SetupCandidate[] = []
35
+
36
+ for (const project of projects) {
37
+ const seen = seenByProject.get(project.id)
38
+ const workspaces = project.workspaces ?? []
39
+
40
+ // First sight of a project: record what it already has and run nothing.
41
+ // Without this, switching to a project surfaces N pre-existing workspaces
42
+ // at once and would fire N setups.
43
+ if (!seen) {
44
+ seenByProject.set(project.id, new Set(workspaces.map((w) => w.id)))
45
+ continue
46
+ }
47
+
48
+ for (const workspace of workspaces) {
49
+ if (seen.has(workspace.id)) continue
50
+ seen.add(workspace.id)
51
+
52
+ // The primary workspace *is* the user's real repo checkout. Running a
53
+ // script that does `cp .env.example .env` over it would clobber their
54
+ // actual .env.
55
+ if (workspace.source === 'primary') continue
56
+ if (!hasScript(project.id)) continue
57
+ if (findSetupTab(tabs, workspace.id)) continue
58
+
59
+ candidates.push({ projectId: project.id, workspace })
60
+ }
61
+ }
62
+
63
+ return candidates
64
+ }
65
+
66
+ /**
67
+ * Runs a project's setup script once per newly created workspace.
68
+ *
69
+ * ponytail: a workspace created by the CLI while the app is closed is seeded as
70
+ * pre-existing on the next boot and never auto-runs. The widget's Run button
71
+ * covers it; compare createdAt against setupRanAt if that turns out to matter.
72
+ *
73
+ * The outcome is recorded by `recordSetupExitIfHidden`, called from the
74
+ * backend's exit event, which already carries the exit code.
75
+ */
76
+ export function useSetupRunner(state: AppState, ctx: SideEffectContext): void {
77
+ const seenByProject = useRef(new Map<string, Set<string>>())
78
+ const ctxRef = useRef(ctx)
79
+ ctxRef.current = ctx
80
+
81
+ useEffect(() => {
82
+ // Tabs are read fresh rather than taken as a dependency: their array
83
+ // identity changes on every viewport frame, which would re-run this at the
84
+ // rate the PTYs print. The store is also the more correct source — the
85
+ // render snapshot can lag a setup tab that was just added.
86
+ const candidates = collectSetupCandidates(
87
+ state.projects,
88
+ ctxRef.current.getState().tabs,
89
+ seenByProject.current,
90
+ hasSetupScript
91
+ )
92
+ for (const { projectId, workspace } of candidates) {
93
+ runSetup(ctxRef.current, projectId, workspace)
94
+ }
95
+ }, [state.projects])
96
+ }
@@ -10,10 +10,11 @@ import {
10
10
 
11
11
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
12
12
  import type { SessionBackend } from '../session-backend/types'
13
- import type { AppAction, AppState } from '../state/types'
13
+ import type { AppAction } from '../state/actions'
14
+ import type { AppState } from '../state/types'
14
15
  import type { MeasuredPaneRect } from './use-pane-size-report'
15
16
 
16
- import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
17
+ import { getBarWidth } from '../state/bars'
17
18
  import {
18
19
  createTerminalBounds,
19
20
  forEachSplitPaneRect,
@@ -151,7 +152,10 @@ export function useTerminalResize({
151
152
  const handledBySyncRef = useRef(false)
152
153
  const sidebarMountedRef = useRef(false)
153
154
 
154
- const currentTabIds = state.tabs.map((t) => t.id)
155
+ // Hidden tabs are excluded from the open-loop cascade: their host (the setup
156
+ // widget) measures its own box, and the cascade would overwrite that with the
157
+ // main terminal area's size on every terminal or bar resize.
158
+ const currentTabIds = state.tabs.filter((t) => t.hidden !== true).map((t) => t.id)
155
159
  const tabIdsChanged =
156
160
  currentTabIds.length !== tabIdsRef.current.length ||
157
161
  currentTabIds.some((id, i) => id !== tabIdsRef.current[i])
@@ -179,15 +183,15 @@ export function useTerminalResize({
179
183
  // loop so an unchanged box never re-triggers a resize.
180
184
  const measuredRef = useRef(new Map<string, { cols: number; rows: number }>())
181
185
 
182
- // Drop the dedupe cache when the workspace changes. attach() re-gates every
186
+ // Drop the dedupe cache when the project changes. attach() re-gates every
183
187
  // restored tab's paneReady to false; if a tabId carries the same dimensions
184
- // across workspaces, handleMeasure's prev-match would short-circuit and
188
+ // across projects, handleMeasure's prev-match would short-circuit and
185
189
  // never call resizeTab({confirmedFromMeasurement:true}), leaving the gate
186
190
  // closed forever — the new pane then paints a stale buffered snapshot from
187
- // the old workspace, which is the 2–3 row offset users see.
188
- const lastSessionIdRef = useRef(state.currentSessionId)
189
- if (lastSessionIdRef.current !== state.currentSessionId) {
190
- lastSessionIdRef.current = state.currentSessionId
191
+ // the old project, which is the 2–3 row offset users see.
192
+ const lastProjectIdRef = useRef(state.currentProjectId)
193
+ if (lastProjectIdRef.current !== state.currentProjectId) {
194
+ lastProjectIdRef.current = state.currentProjectId
191
195
  measuredRef.current.clear()
192
196
  }
193
197
 
@@ -223,29 +227,30 @@ export function useTerminalResize({
223
227
  [backend, contentOriginRef]
224
228
  )
225
229
 
226
- const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
230
+ // `getBarWidth` is the single authority on bar width — the Bar component
231
+ // renders from the same function. A mismatch silently corrupts PTY columns
232
+ // and mouse hit-testing.
233
+ const leftBarWidth = getBarWidth(state.bars.left)
234
+ const rightBarWidth = getBarWidth(state.bars.right)
227
235
  const terminalSize = useMemo(() => {
228
- const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
229
- const sessionBarRows = state.sessionBar.visible ? 1 : 0
230
- const sessionBarTopOffset = sessionBarRows
236
+ const projectBarRows = state.projectBar.visible ? 1 : 0
237
+ const projectBarTopOffset = projectBarRows
231
238
  const reservedRows =
232
239
  MAIN_AREA_VERTICAL_PADDING +
233
240
  STATUS_BAR_HEIGHT +
234
241
  TERMINAL_PANE_VERTICAL_CHROME +
235
- sessionBarRows
236
- const gitPaneWidth = gitPaneInPaneMode ? getGitPaneWidthFromRatio(state.gitPane.paneRatio) : 0
237
- const gitOnLeft = gitPaneInPaneMode && state.gitPane.position === 'left'
242
+ projectBarRows
238
243
  const cols = Math.max(
239
244
  MIN_TERMINAL_COLS,
240
- Math.floor(dimensions.width - sidebarWidth - gitPaneWidth - MAIN_AREA_HORIZONTAL_CHROME)
245
+ Math.floor(dimensions.width - leftBarWidth - rightBarWidth - MAIN_AREA_HORIZONTAL_CHROME)
241
246
  )
242
247
  const rows = Math.max(MIN_TERMINAL_ROWS, Math.floor(dimensions.height - reservedRows))
243
248
 
244
249
  contentOriginRef.current = {
245
250
  cols,
246
251
  rows,
247
- x: sidebarWidth + (gitOnLeft ? gitPaneWidth : 0) + 1,
248
- y: 1 + sessionBarTopOffset,
252
+ x: leftBarWidth + 1,
253
+ y: 1 + projectBarTopOffset,
249
254
  }
250
255
 
251
256
  return { cols, rows }
@@ -253,12 +258,9 @@ export function useTerminalResize({
253
258
  contentOriginRef,
254
259
  dimensions.height,
255
260
  dimensions.width,
256
- state.sidebar.visible,
257
- state.sidebar.width,
258
- state.sessionBar.visible,
259
- gitPaneInPaneMode,
260
- state.gitPane.position,
261
- state.gitPane.paneRatio,
261
+ leftBarWidth,
262
+ rightBarWidth,
263
+ state.projectBar.visible,
262
264
  ])
263
265
 
264
266
  useLayoutEffect(() => {
@@ -280,14 +282,7 @@ export function useTerminalResize({
280
282
  })
281
283
  handledBySyncRef.current = true
282
284
  // eslint-disable-next-line react-hooks/exhaustive-deps
283
- }, [
284
- state.sidebar.visible,
285
- state.sidebar.width,
286
- state.sessionBar.visible,
287
- gitPaneInPaneMode,
288
- state.gitPane.position,
289
- state.gitPane.paneRatio,
290
- ])
285
+ }, [leftBarWidth, rightBarWidth, state.projectBar.visible])
291
286
 
292
287
  useEffect(() => {
293
288
  if (handledBySyncRef.current) {
@@ -0,0 +1,366 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ import type { AppState, WorkspaceRecord } from '../state/types'
6
+ import type { SideEffectContext } from './side-effect-context'
7
+
8
+ import { moveWorkspace } from '../git/move-workspace'
9
+ import {
10
+ createGitWorktree,
11
+ deleteGitBranch,
12
+ getCurrentBranch,
13
+ getHeadSha,
14
+ getMainWorktreeRoot,
15
+ listGitWorktrees,
16
+ pruneGitWorktrees,
17
+ removeGitWorktree,
18
+ } from '../git/worktree'
19
+ import { createPrefixedId } from '../platform/id'
20
+ import {
21
+ assertSafeAimuxWorktreePath,
22
+ isInsideAimuxWorktreeRoot,
23
+ makeWorktreePath,
24
+ sanitizePathSegment,
25
+ } from '../platform/worktree-paths'
26
+ import { saveProjectCatalog } from '../state/project-catalog'
27
+ import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
28
+ import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
29
+ import { toast } from '../state/toast-store'
30
+
31
+ /**
32
+ * Rewrite one project and hand back the whole list, ready for `set-projects`.
33
+ *
34
+ * Reads the *live* store rather than `ctx.state`: callers reach here after
35
+ * seconds of git work, and `ctx.state` is the render snapshot from before it.
36
+ * Since `set-projects` replaces the array wholesale, a stale read does not just
37
+ * miss an update — it reverts it. Workspace naming lands asynchronously, so
38
+ * that window is wide open.
39
+ */
40
+ export function replaceProject(
41
+ ctx: SideEffectContext,
42
+ projectId: string,
43
+ next: (project: AppState['projects'][number]) => AppState['projects'][number]
44
+ ): AppState['projects'] {
45
+ return ctx
46
+ .getState()
47
+ .projects.map((project) => (project.id === projectId ? next(project) : project))
48
+ }
49
+
50
+ export function handleSwitchWorkspace(
51
+ ctx: SideEffectContext,
52
+ projectId: string,
53
+ workspaceId: string
54
+ ): void {
55
+ const project = ctx.state.projects.find((entry) => entry.id === projectId)
56
+ const workspace = project?.workspaces?.find((entry) => entry.id === workspaceId)
57
+ if (!project || !workspace) return
58
+ const projects = replaceProject(ctx, projectId, (entry) => ({
59
+ ...entry,
60
+ activeWorkspaceId: workspaceId,
61
+ updatedAt: new Date().toISOString(),
62
+ }))
63
+ saveProjectCatalog(projects)
64
+ ctx.dispatch({ projects, type: 'set-projects' })
65
+ }
66
+
67
+ function normalizeBranchName(branch: string | undefined): string | undefined {
68
+ return branch?.replace(/^refs\/heads\//, '').trim()
69
+ }
70
+
71
+ export async function createAimuxTempWorkspace(
72
+ ctx: SideEffectContext,
73
+ projectId: string,
74
+ requestedName?: string,
75
+ requestedBranchName?: string,
76
+ requestedBaseRef?: string,
77
+ sourceWorkspaceId?: string
78
+ ): Promise<WorkspaceRecord | undefined> {
79
+ const project = ctx.state.projects.find((entry) => entry.id === projectId)
80
+ const source =
81
+ project?.workspaces?.find((entry) => entry.id === sourceWorkspaceId) ??
82
+ getActiveWorkspace(project)
83
+ const sourcePath = source?.path ?? getActiveWorkspacePath(project)
84
+ if (!project || !(sourcePath != null && sourcePath !== '')) return undefined
85
+
86
+ // Resolve the *main* repo checkout, never the active linked workspace, so the
87
+ // record's repoRoot stays valid after sibling workspaces are deleted.
88
+ const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
89
+ const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
90
+ const baseRef = requestedBaseRef ?? baseBranch
91
+ const workspaceId = createPrefixedId('workspace')
92
+ const trimmedName = requestedName?.trim()
93
+ const workspaceName =
94
+ trimmedName != null && trimmedName !== ''
95
+ ? trimmedName
96
+ : `wt-${sanitizePathSegment(project.name, 12)}`
97
+ const trimmedBranch = requestedBranchName?.trim()
98
+ const branchName =
99
+ trimmedBranch != null && trimmedBranch !== ''
100
+ ? trimmedBranch
101
+ : `aimux/${sanitizePathSegment(workspaceName, 40)}-${Date.now().toString(36)}`
102
+ const targetPath = makeWorktreePath({ repoRoot, workspaceId, workspaceName })
103
+
104
+ const existingWorkspace = (await listGitWorktrees(repoRoot)).find(
105
+ (entry) =>
106
+ entry.prunable !== true &&
107
+ normalizeBranchName(entry.branch) === normalizeBranchName(branchName)
108
+ )
109
+ if (existingWorkspace) {
110
+ ctx.dispatch({
111
+ message: `Branch already checked out in another workspace: ${existingWorkspace.path}`,
112
+ type: 'set-create-workspace-branch-error',
113
+ })
114
+ return undefined
115
+ }
116
+
117
+ await mkdir(dirname(targetPath), { recursive: true })
118
+ await assertSafeAimuxWorktreePath(targetPath)
119
+ await createGitWorktree({ baseRef, branchName, repoPath: repoRoot, targetPath })
120
+ const now = new Date().toISOString()
121
+
122
+ const workspace: WorkspaceRecord = {
123
+ baseRef,
124
+ branch: branchName,
125
+ commitSha: await getHeadSha(targetPath),
126
+ createdAt: now,
127
+ createdByAimux: true,
128
+ id: workspaceId,
129
+ name: workspaceName,
130
+ path: targetPath,
131
+ repoRoot,
132
+ source: 'aimux-temp',
133
+ updatedAt: now,
134
+ }
135
+ const projects = replaceProject(ctx, projectId, (entry) => ({
136
+ ...entry,
137
+ activeWorkspaceId: workspace.id,
138
+ updatedAt: now,
139
+ workspaces: [...(entry.workspaces ?? []), workspace],
140
+ }))
141
+ saveProjectCatalog(projects)
142
+ ctx.dispatch({ projects, type: 'set-projects' })
143
+ toast.success(`Created workspace ${branchName}`)
144
+ return workspace
145
+ }
146
+
147
+ // Dispose and close every tab pinned to a workspace (timers, pty project, state).
148
+ function disposeWorkspaceTabs(ctx: SideEffectContext, workspaceId: string): void {
149
+ for (const tab of ctx.state.tabs.filter((entry) => entry.workspaceId === workspaceId)) {
150
+ ctx.clearIdleTimer(tab.id)
151
+ ctx.clearStartupGrace(tab.id)
152
+ ctx.backend.disposeSession(tab.id)
153
+ ctx.dispatch({ tabId: tab.id, type: 'close-tab' })
154
+ }
155
+ }
156
+
157
+ export async function runDeleteWorkspace(
158
+ ctx: SideEffectContext,
159
+ projectId: string,
160
+ workspaceId: string,
161
+ force: boolean,
162
+ closeTabs = false
163
+ ): Promise<void> {
164
+ const project = ctx.state.projects.find((entry) => entry.id === projectId)
165
+ const workspace = project?.workspaces?.find((entry) => entry.id === workspaceId)
166
+ if (!project) throw new Error('project not found')
167
+ if (!workspace) throw new Error('workspace not found')
168
+ if ((project.workspaces?.length ?? 0) <= 1) throw new Error('at least one workspace must remain')
169
+ if (workspace.source === 'primary') throw new Error('root workspace cannot be deleted')
170
+
171
+ const tabsInWorkspace = ctx.state.tabs.filter((tab) => tab.workspaceId === workspaceId)
172
+ // The active-tabs guard asks the modal user to confirm before closing tabs.
173
+ // `closeTabs` (the sidebar's "Remove workspace") opts into closing them
174
+ // directly without forcing the git removal, so dirty temp workspaces are still
175
+ // protected by the non-force `git worktree remove`.
176
+ if (tabsInWorkspace.length > 0 && !force && !closeTabs) {
177
+ throw new ActiveWorkspaceTabsError(tabsInWorkspace.length)
178
+ }
179
+
180
+ const repoPath = resolveWorkspaceGitDir(project, workspace)
181
+ const isAimuxTemp = workspace.source === 'aimux-temp' && workspace.createdByAimux
182
+ // Drop the throwaway aimux branch alongside the workspace so deleted temp
183
+ // workspaces don't accumulate in the repo or haunt the base picker. Scoped to
184
+ // the `aimux/` namespace (matches the picker filter); best-effort.
185
+ const cleanupAimuxBranch = async (): Promise<void> => {
186
+ const branch = workspace.branch
187
+ if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
188
+ await deleteGitBranch(repoPath, branch)
189
+ }
190
+ }
191
+
192
+ // Run git ops FIRST. If any throws (dirty workspace, etc.) the catch in the
193
+ // delete-workspace side effect handler re-prompts force or toasts the error —
194
+ // tabs stay open and the row stays in the sidebar, so the UI matches reality
195
+ // instead of leaving tabs closed against a workspace that still exists.
196
+ if (isAimuxTemp && isInsideAimuxWorktreeRoot(workspace.path) && !existsSync(workspace.path)) {
197
+ // The dir vanished but git may still pin the branch to a stale workspace entry.
198
+ await pruneGitWorktrees(repoPath)
199
+ } else if (isAimuxTemp && isInsideAimuxWorktreeRoot(workspace.path)) {
200
+ await assertSafeAimuxWorktreePath(workspace.path)
201
+ await removeGitWorktree({ force, repoPath, targetPath: workspace.path })
202
+ } else if (workspace.source === 'aimux-temp' || workspace.createdByAimux) {
203
+ throw new Error(`refusing unsafe workspace delete: ${workspace.path}`)
204
+ }
205
+
206
+ await cleanupAimuxBranch()
207
+
208
+ // Only after git success: close tabs + retire the record. Re-read state so
209
+ // we don't operate on the snapshot captured before the awaits above.
210
+ disposeWorkspaceTabs({ ...ctx, state: ctx.getState() }, workspaceId)
211
+ const latest = ctx.getState()
212
+ const latestProject = latest.projects.find((entry) => entry.id === projectId)
213
+ if (latestProject) {
214
+ removeWorkspaceRecordFromProject(
215
+ { ...ctx, state: latest },
216
+ projectId,
217
+ latestProject,
218
+ workspaceId
219
+ )
220
+ }
221
+ }
222
+
223
+ // A workspace's stored repoRoot can point at a sibling workspace that was since
224
+ // deleted. Git commands only need *some* existing workspace of the repo (they
225
+ // share the common git dir), so fall back to the main checkout or any live
226
+ // workspace path rather than letting `git -C` fail on a vanished directory.
227
+ function resolveWorkspaceGitDir(
228
+ project: NonNullable<SideEffectContext['state']['projects'][number]>,
229
+ workspace: WorkspaceRecord
230
+ ): string {
231
+ const candidates = [
232
+ project.workspaces?.find((entry) => entry.source === 'primary')?.path,
233
+ workspace.repoRoot,
234
+ ...(project.workspaces ?? [])
235
+ .filter((entry) => entry.id !== workspace.id)
236
+ .map((entry) => entry.path),
237
+ ]
238
+ return candidates.find((path) => path !== undefined && existsSync(path)) ?? workspace.repoRoot
239
+ }
240
+
241
+ export async function runMoveWorkspace(
242
+ ctx: SideEffectContext,
243
+ projectId: string,
244
+ sourceWorkspaceId: string,
245
+ targetWorkspaceId: string,
246
+ deleteSource: boolean,
247
+ stashTarget: boolean,
248
+ keepConflicts: boolean
249
+ ): Promise<void> {
250
+ const project = ctx.state.projects.find((entry) => entry.id === projectId)
251
+ const source = project?.workspaces?.find((entry) => entry.id === sourceWorkspaceId)
252
+ const target = project?.workspaces?.find((entry) => entry.id === targetWorkspaceId)
253
+ if (!project) throw new Error('project not found')
254
+ if (!source || !target) throw new Error('workspace not found')
255
+ if (source.id === target.id) throw new Error('source and target are the same workspace')
256
+ if (source.branch == null || source.branch === '') {
257
+ throw new Error('source workspace has no branch to move')
258
+ }
259
+ if (deleteSource && source.source === 'primary') {
260
+ throw new Error('the primary workspace cannot be deleted')
261
+ }
262
+
263
+ const sourceLabel = source.branch ?? source.name
264
+ const targetLabel = target.branch ?? target.name
265
+ const result = await moveWorkspace({
266
+ keepConflicts,
267
+ sourceBranch: source.branch,
268
+ sourcePath: source.path,
269
+ stashTarget,
270
+ targetPath: target.path,
271
+ })
272
+
273
+ // Recoverable failures open a confirm dialog carrying retry params; both
274
+ // workspaces are already back in their original state, so confirming simply
275
+ // re-dispatches move-workspace with the matching flag.
276
+ if (result.kind === 'needs-stash' || result.kind === 'conflict') {
277
+ ctx.dispatch({
278
+ deleteSource,
279
+ files: result.files,
280
+ projectId,
281
+ sourceLabel,
282
+ sourceWorkspaceId,
283
+ targetLabel,
284
+ targetWorkspaceId,
285
+ type: 'open-workspace-move-confirm',
286
+ variant: result.kind === 'needs-stash' ? 'stash-target' : 'keep-conflicts',
287
+ })
288
+ return
289
+ }
290
+ if (result.kind === 'conflict-kept') {
291
+ // Never delete the source here — its work only landed half-resolved. The
292
+ // auto-commit driver is safe against this state: git refuses to commit
293
+ // with unmerged index entries, so it fails loudly instead of committing
294
+ // conflict markers.
295
+ handleSwitchWorkspace({ ...ctx, state: ctx.getState() }, projectId, targetWorkspaceId)
296
+ toast.warning(
297
+ `Left conflict markers in ${targetLabel} (${result.files.length} file(s)) — resolve & commit there; ${sourceLabel} kept`
298
+ )
299
+ return
300
+ }
301
+ if (result.kind === 'error') {
302
+ toast.error(`Move failed: ${result.message}`)
303
+ return
304
+ }
305
+
306
+ // Land on the target. When deleting the source, close its terminals and
307
+ // switch to the target up front, synchronously, BEFORE the slow
308
+ // `git worktree remove`. Closing the source's active tab re-syncs the active
309
+ // workspace to a default tab (withActiveTabWorkspace); doing the close+switch in
310
+ // one batch lands on the target with no intermediate render, so the removal
311
+ // runs with the target already active instead of flashing/sticking to a
312
+ // default workspace. Re-read state each step so we never resurrect the source.
313
+ if (deleteSource) {
314
+ disposeWorkspaceTabs({ ...ctx, state: ctx.getState() }, sourceWorkspaceId)
315
+ handleSwitchWorkspace({ ...ctx, state: ctx.getState() }, projectId, targetWorkspaceId)
316
+ await runDeleteWorkspace({ ...ctx, state: ctx.getState() }, projectId, sourceWorkspaceId, true)
317
+ } else {
318
+ handleSwitchWorkspace({ ...ctx, state: ctx.getState() }, projectId, targetWorkspaceId)
319
+ }
320
+ const stashNote = result.stashedTarget
321
+ ? ` · target's previous changes stashed (recover with git stash pop)`
322
+ : ''
323
+ toast.success(
324
+ `Moved ${sourceLabel} → ${targetLabel} · ${result.filesChanged} file(s) staged — review & commit${stashNote}`
325
+ )
326
+ }
327
+
328
+ export function removeWorkspaceRecordFromProject(
329
+ ctx: SideEffectContext,
330
+ projectId: string,
331
+ project: NonNullable<SideEffectContext['state']['projects'][number]>,
332
+ workspaceId: string
333
+ ): void {
334
+ const projects = replaceProject(ctx, projectId, (entry) => {
335
+ // Filtered from `entry`, not from the `project` this was called with: a
336
+ // workspace created while the delete's git work ran is in one and not the
337
+ // other, and rebuilding from the stale copy would delete it too.
338
+ const remaining = (entry.workspaces ?? []).filter((w) => w.id !== workspaceId)
339
+ return {
340
+ ...entry,
341
+ activeWorkspaceId: (entry.activeWorkspaceId === workspaceId
342
+ ? remaining[0]
343
+ : getActiveWorkspace(entry)
344
+ )?.id,
345
+ projectSnapshot: pruneSnapshotOfWorkspace(entry.projectSnapshot, workspaceId),
346
+ updatedAt: new Date().toISOString(),
347
+ workspaces: remaining,
348
+ }
349
+ })
350
+ saveProjectCatalog(projects)
351
+ ctx.dispatch({ projects, type: 'set-projects' })
352
+ }
353
+
354
+ export function isForceableWorkspaceDeleteError(message: string): boolean {
355
+ return /active assistant tabs|dirty|uncommitted|modified|untracked|not clean|contains.*changes/i.test(
356
+ message
357
+ )
358
+ }
359
+
360
+ class ActiveWorkspaceTabsError extends Error {
361
+ constructor(tabCount: number) {
362
+ super(
363
+ `active assistant tabs are using this workspace (${tabCount}) — they will be closed if you confirm.`
364
+ )
365
+ }
366
+ }