@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
@@ -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
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Per-workspace activity, and the notification sound that goes with it.
3
+ *
4
+ * The store only holds tabs for the project the client is attached to, but the
5
+ * daemon broadcasts every tab's status with its `workspaceId` (v18). So the
6
+ * table of "which tab is doing what" lives here, module-owned, and only the
7
+ * aggregate per workspace is dispatched into `AppState.workspaceActivity` —
8
+ * which is what the sidebar draws for every project, current or not.
9
+ *
10
+ * Both notification sounds are triggered from here so the glyph and the sound
11
+ * can never disagree about what happened:
12
+ * - a tab entering `waiting-input` (the agent is asking something),
13
+ * - a tab completing a turn (the daemon's settled end-of-turn signal).
14
+ * Neither fires for the tab you are looking at.
15
+ */
16
+ import type { TabActivity } from '../state/types'
17
+
18
+ import { playNotificationSound } from '../settings/sections/notifications'
19
+ import { appStore } from '../state/app-store'
20
+ import { dispatchGlobal } from '../state/dispatch-ref'
21
+
22
+ interface Entry {
23
+ status: TabActivity
24
+ workspaceId: string | undefined
25
+ /**
26
+ * Whether this client has seen the tab do something since the last turn it
27
+ * announced. The daemon's end-of-turn timer is per-process: restart it (an
28
+ * update, `restart-daemon`) and every surviving idle PTY produces a
29
+ * turn-complete a settle window later. Requiring an observed `working` /
30
+ * `waiting-input` first is what keeps that restart from ticking every
31
+ * workspace and ringing.
32
+ */
33
+ armed: boolean
34
+ }
35
+
36
+ // A tab in another project never sends this client its `tabExit`, so an entry
37
+ // here can outlive its PTY. The daemon emits a final `idle` for a tab it stops
38
+ // seeing (the tick cleanup in `assistant-status-detection-loop.ts`), which is
39
+ // what keeps a tab that died mid-turn from pinning a spinner to a workspace
40
+ // row forever; the entry itself is left behind, contributing nothing.
41
+ const entries = new Map<string, Entry>()
42
+
43
+ /** The two flags a set of tabs adds up to. Pure, and the reason it is exported. */
44
+ export function aggregate(statuses: readonly TabActivity[]): {
45
+ working: boolean
46
+ waiting: boolean
47
+ } {
48
+ return {
49
+ waiting: statuses.includes('waiting-input'),
50
+ working: statuses.includes('working'),
51
+ }
52
+ }
53
+
54
+ function publish(workspaceId: string | undefined): void {
55
+ if (workspaceId == null || workspaceId === '') return
56
+ const statuses: TabActivity[] = []
57
+ for (const entry of entries.values()) {
58
+ if (entry.workspaceId === workspaceId) statuses.push(entry.status)
59
+ }
60
+ const { waiting, working } = aggregate(statuses)
61
+ dispatchGlobal({ type: 'set-workspace-activity', waiting, working, workspaceId })
62
+ }
63
+
64
+ /** True when this tab is the one on screen — no sound, no unseen-tick, for it. */
65
+ function isBeingWatched(tabId: string): boolean {
66
+ return appStore.getState().activeTabId === tabId
67
+ }
68
+
69
+ export function recordTabStatus(
70
+ tabId: string,
71
+ status: TabActivity,
72
+ workspaceId: string | undefined,
73
+ options?: { silent?: boolean }
74
+ ): void {
75
+ const previous = entries.get(tabId)
76
+ const armed = status === 'working' || status === 'waiting-input' || (previous?.armed ?? false)
77
+ // An event that carries no workspace does not mean the tab lost one. A tab
78
+ // created before the daemon recorded workspaces has none in its registry,
79
+ // but the client's own snapshot does — and the attach seeded us with it, so
80
+ // keep it rather than blanking the row on the next status change.
81
+ const known = workspaceId ?? previous?.workspaceId
82
+ entries.set(tabId, { armed, status, workspaceId: known })
83
+ // A tab can be reassigned to another workspace (workspace move); the one it
84
+ // left has one tab fewer and has to be recomputed too.
85
+ if (previous && previous.workspaceId !== known) publish(previous.workspaceId)
86
+ publish(known)
87
+
88
+ if (
89
+ options?.silent !== true &&
90
+ status === 'waiting-input' &&
91
+ previous?.status !== 'waiting-input' &&
92
+ !isBeingWatched(tabId)
93
+ ) {
94
+ playNotificationSound()
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Seed from the tabs an attach handed us, so a workspace whose agent has been
100
+ * waiting since before this client started says so on the first paint instead
101
+ * of waiting for a transition that already happened. Silent by definition:
102
+ * nothing here is news.
103
+ */
104
+ export function seedTabActivity(
105
+ tabs: readonly { id: string; activity?: TabActivity; workspaceId?: string }[]
106
+ ): void {
107
+ for (const tab of tabs) {
108
+ recordTabStatus(tab.id, tab.activity ?? 'idle', tab.workspaceId, { silent: true })
109
+ }
110
+ }
111
+
112
+ export function recordTurnComplete(tabId: string, workspaceId: string | undefined): void {
113
+ const entry = entries.get(tabId)
114
+ // Nothing observed since the last turn — this is a restarted daemon
115
+ // re-announcing the end of a turn that ended before we were listening.
116
+ if (entry?.armed !== true) return
117
+ entry.armed = false
118
+ if (isBeingWatched(tabId)) return
119
+ if (workspaceId != null && workspaceId !== '') {
120
+ dispatchGlobal({ type: 'mark-workspace-done', workspaceId })
121
+ }
122
+ // The workspace tick says where to look; this says which tab rang. A no-op
123
+ // for a tab this client doesn't hold — its workspace row is the answer there.
124
+ dispatchGlobal({ tabId, type: 'mark-tab-unseen' })
125
+ playNotificationSound()
126
+ }
127
+
128
+ export function forgetTabActivity(tabId: string): void {
129
+ const entry = entries.get(tabId)
130
+ if (!entry) return
131
+ entries.delete(tabId)
132
+ publish(entry.workspaceId)
133
+ }
134
+
135
+ /** Test seam: drop every remembered tab. */
136
+ export function resetWorkspaceActivity(): void {
137
+ entries.clear()
138
+ }
@@ -0,0 +1,90 @@
1
+ // Names a workspace after the prompt that created it.
2
+ //
3
+ // Two stages, because a model call takes seconds and workspace creation must
4
+ // not wait on the network: a local heuristic name is applied at creation, then
5
+ // the generated one replaces it in place when it arrives. The heuristic result
6
+ // is not a stopgap to be embarrassed about — when no headless CLI is installed
7
+ // it is the final name, and it is still derived from what the user asked for.
8
+
9
+ import type { AssistantId, WorkspaceRecord } from '../state/types'
10
+
11
+ import { heuristicTitle } from '../auto-rename/heuristic-title'
12
+ import { generateTabTitle, type TitleSpawnFn } from '../auto-rename/title-runner'
13
+ import { renameGitBranch } from '../git/worktree'
14
+ import { sanitizePathSegment } from '../platform/worktree-paths'
15
+
16
+ /** Model naming is best-effort background work; it must never outlive the app. */
17
+ const NAMING_TIMEOUT_MS = 30_000
18
+
19
+ export const BRANCH_PREFIX = 'aimux/'
20
+ const BRANCH_SLUG_MAX = 40
21
+
22
+ export function branchNameFor(name: string): string {
23
+ return `${BRANCH_PREFIX}${sanitizePathSegment(name, BRANCH_SLUG_MAX).toLowerCase()}`
24
+ }
25
+
26
+ /**
27
+ * The name a workspace carries until the model answers. Undefined when the
28
+ * prompt yields nothing usable, which lets `createAimuxTempWorkspace` fall back
29
+ * to its own `wt-<project>` default.
30
+ */
31
+ export function placeholderWorkspaceName(prompt: string): string | undefined {
32
+ return heuristicTitle(prompt) ?? undefined
33
+ }
34
+
35
+ export interface WorkspaceNamingTarget {
36
+ projectId: string
37
+ workspace: WorkspaceRecord
38
+ prompt: string
39
+ /** Provider used for the naming call — the assistant the user just picked. */
40
+ provider: AssistantId
41
+ }
42
+
43
+ export interface WorkspaceNamingDeps {
44
+ applyName: (projectId: string, workspaceId: string, patch: Partial<WorkspaceRecord>) => void
45
+ renameBranch?: (repoRoot: string, from: string, to: string) => Promise<boolean>
46
+ spawn?: TitleSpawnFn
47
+ signal?: AbortSignal
48
+ }
49
+
50
+ /**
51
+ * Replace a workspace's placeholder name and branch with ones generated from
52
+ * the prompt. Silent on every failure: a missing CLI, a timeout or a branch
53
+ * collision all leave the placeholder standing, which is a perfectly usable
54
+ * outcome and not worth a toast.
55
+ *
56
+ * ponytail: fixed timeout, no model override. Read `autoRename.models` here if
57
+ * anyone wants naming on a cheaper model than the tab's assistant.
58
+ */
59
+ export async function renameWorkspaceFromPrompt(
60
+ target: WorkspaceNamingTarget,
61
+ deps: WorkspaceNamingDeps
62
+ ): Promise<void> {
63
+ const result = await generateTabTitle({
64
+ firstPrompt: target.prompt,
65
+ provider: target.provider,
66
+ signal: deps.signal ?? new AbortController().signal,
67
+ spawn: deps.spawn,
68
+ timeoutMs: NAMING_TIMEOUT_MS,
69
+ })
70
+ if (result.status !== 'ok') return
71
+
72
+ const { workspace } = target
73
+ if (result.title === workspace.name) return
74
+
75
+ const branch = branchNameFor(result.title)
76
+ const rename = deps.renameBranch ?? renameGitBranch
77
+ // Renamed from inside the workspace, not the main checkout: the branch is
78
+ // that worktree's current branch, which is the form git always accepts.
79
+ // Renaming first means a refusal (the name is taken) leaves the record
80
+ // pointing at the branch that actually exists.
81
+ const branchRenamed =
82
+ workspace.branch != null && workspace.branch !== '' && branch !== workspace.branch
83
+ ? await rename(workspace.path, workspace.branch, branch)
84
+ : false
85
+
86
+ deps.applyName(target.projectId, workspace.id, {
87
+ name: result.title,
88
+ ...(branchRenamed ? { branch } : null),
89
+ })
90
+ }