@brimveyn/aimux 1.22.5 → 1.22.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.22.5",
3
+ "version": "1.22.7",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -1,7 +1,6 @@
1
1
  import { isAutoCommitEnabled } from '@brimveyn/aimux-config'
2
2
 
3
3
  import type { SideEffect } from '../input/modes/types'
4
- import type { AssistantId, PendingWorkspaceLaunch } from '../state/types'
5
4
  import type { SideEffectContext } from './side-effect-context'
6
5
 
7
6
  import { loadConfig, saveConfig } from '../config'
@@ -9,7 +8,6 @@ import { logInputDebug } from '../debug/input-log'
9
8
  import { enqueueGitOp } from '../git/command-queue'
10
9
  import { countDirtyFiles } from '../git/move-workspace'
11
10
  import { getCurrentBranch, getDefaultBranch, listLocalBranches } from '../git/worktree'
12
- import { assistantAcceptsPromptArg } from '../pty/command-registry'
13
11
  import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
14
12
  import { saveCurrentProject } from '../state/project-save'
15
13
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
@@ -39,7 +37,6 @@ import {
39
37
  handleSwitchProjectEffect,
40
38
  restartTabSession,
41
39
  } from './project-actions'
42
- import { injectPromptWhenReady } from './prompt-injection'
43
40
  import { getSelectedAssistantOption, getSelectedProject, getSelectedSnippet } from './selection'
44
41
  import {
45
42
  changeSelectedSetting,
@@ -48,7 +45,6 @@ import {
48
45
  resetSelectedSetting,
49
46
  } from './settings-actions'
50
47
  import {
51
- findSetupTab,
52
48
  handleAskAgentForSetupScriptEffect,
53
49
  handleConfigureSetupScriptEffect,
54
50
  handlePromoteSetupTabEffect,
@@ -64,17 +60,16 @@ import {
64
60
  confirmSplitSelection,
65
61
  createTabSession,
66
62
  executeSplitPane,
67
- launchAssistant,
63
+ launchWithPrompt,
68
64
  startExistingTab,
69
65
  } from './tab-actions'
70
66
  import {
71
- createAimuxTempWorkspace,
72
67
  isForceableWorkspaceDeleteError,
73
68
  runDeleteWorkspace,
74
69
  runMoveWorkspace,
75
70
  setProjectDefaultBaseRef,
76
71
  } from './workspace-actions'
77
- import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
72
+ import { launchPendingWorkspace, startWorkspaceCreation } from './workspace-launch'
78
73
 
79
74
  function handleProjectSelection(ctx: SideEffectContext): void {
80
75
  const { backend, dispatch, state } = ctx
@@ -214,79 +209,6 @@ function hoistBranch(branches: string[], first: string | undefined): string[] {
214
209
  return [first, ...branches.filter((branch) => branch !== first)]
215
210
  }
216
211
 
217
- /**
218
- * Setup runs concurrently with the agent by design, so say so rather than
219
- * letting the agent run tests against a half-installed tree and draw the wrong
220
- * conclusion. Only prefixed when a setup is actually live.
221
- */
222
- function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
223
- const setupRunning = findSetupTab(ctx.getState().tabs, pending.workspaceId)?.status === 'running'
224
- if (!setupRunning) return pending.prompt
225
- return `Note: a setup script is currently installing this workspace's dependencies in the background. Wait for it to finish before running builds, tests, or anything that reads installed dependencies.\n\n${pending.prompt}`
226
- }
227
-
228
- /**
229
- * Name the workspace after what its prompt describes, using the assistant the
230
- * user just picked. Background work that must never block or fail the launch.
231
- *
232
- * Always `pending.prompt`, never the setup-annotated variant built for the
233
- * agent — the note is guidance, not part of what the user asked for.
234
- */
235
- function renameWorkspaceFromLaunch(
236
- ctx: SideEffectContext,
237
- pending: PendingWorkspaceLaunch,
238
- assistant: AssistantId
239
- ): void {
240
- const workspace = ctx
241
- .getState()
242
- .projects.find((entry) => entry.id === pending.projectId)
243
- ?.workspaces?.find((entry) => entry.id === pending.workspaceId)
244
- if (!workspace) return
245
-
246
- void renameWorkspaceFromPrompt(
247
- { projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
248
- {
249
- applyName: (projectId, workspaceId, patch) =>
250
- ctx.dispatch({ patch, projectId, type: 'update-workspace-record', workspaceId }),
251
- }
252
- )
253
- }
254
-
255
- /**
256
- * The `<C-p>` flow: create a workspace in the current project, then chain into
257
- * the new-tab modal rather than leaving the user in an empty workspace.
258
- */
259
- async function createWorkspaceFromModal(
260
- ctx: SideEffectContext,
261
- projectId: string,
262
- params: {
263
- prompt: string
264
- baseRef?: string
265
- }
266
- ): Promise<void> {
267
- // A name derived locally from the prompt, so the sidebar reads right from the
268
- // first frame. The model-generated one replaces it a few seconds later.
269
- // The branch is left to `createAimuxTempWorkspace`, which suffixes it with a
270
- // timestamp: two workspaces started from the same prompt must not collide on
271
- // the branch name before the model has had a chance to distinguish them.
272
- const workspace = await createAimuxTempWorkspace(
273
- ctx,
274
- projectId,
275
- placeholderWorkspaceName(params.prompt),
276
- undefined,
277
- params.baseRef
278
- )
279
- // Undefined means the create was rejected (e.g. branch already checked out);
280
- // the modal stays open showing the error.
281
- if (!workspace) return
282
-
283
- ctx.dispatch({ type: 'close-modal' })
284
- ctx.dispatch({
285
- pendingWorkspace: { projectId, prompt: params.prompt, workspaceId: workspace.id },
286
- type: 'open-new-tab-modal',
287
- })
288
- }
289
-
290
212
  export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): void {
291
213
  const { backend, dispatch, state } = ctx
292
214
 
@@ -313,47 +235,16 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
313
235
  }
314
236
  case 'launch-selected-assistant': {
315
237
  const assistant = getSelectedAssistantOption(state).id
316
- // Chained from `<C-p>`: pin the tab to the workspace just created, hand it
317
- // the prompt, and name the workspace with the assistant the user picked.
318
- // Otherwise the tab lands in the project's active workspace, which
319
- // launchAssistant resolves itself.
320
- const pending = state.modal.type === 'new-tab' ? state.modal.pendingWorkspace : undefined
321
- // Normalized to '' so "is there a prompt" is one comparison rather than a
322
- // null check repeated at each decision below.
323
- const prompt = pending
324
- ? buildWorkspacePrompt(ctx, pending)
325
- : ((state.modal.type === 'new-tab' ? state.modal.pendingPrompt : undefined) ?? '')
326
-
327
- // Hand the prompt to the CLI at spawn where the CLI takes one. Pasting it
328
- // into a live TUI works — it is what this flow did — but it means polling
329
- // for readiness, probing the screen, and retrying. An argv slot has none of
330
- // those failure modes.
331
- const atSpawn = prompt !== '' && assistantAcceptsPromptArg(assistant, state.customCommands)
332
- logInputDebug('app.launchSelectedAssistant', {
333
- assistant,
334
- chained: pending != null,
335
- modal: state.modal.type,
336
- promptAtSpawn: atSpawn,
337
- promptLength: prompt.length,
338
- })
339
-
340
- const tabId = launchAssistant(
341
- ctx,
342
- assistant,
343
- pending?.workspaceId,
344
- atSpawn ? [prompt] : undefined
345
- )
346
- // Delivery is decided and done here, chained or not: two call sites meant
347
- // the prompt could be built twice, from two different reads of the store.
348
- if (prompt !== '' && !atSpawn) {
349
- void injectPromptWhenReady({
350
- backend: ctx.backend,
351
- getState: ctx.getState,
352
- prompt,
353
- tabId,
354
- })
238
+ const newTab = state.modal.type === 'new-tab' ? state.modal : undefined
239
+ // Chained from `<C-p>`: the tab is pinned to the workspace being cut, and
240
+ // waits for it. Otherwise it lands in the project's active workspace,
241
+ // which launchAssistant resolves itself.
242
+ if (newTab?.pendingWorkspace) {
243
+ launchPendingWorkspace(ctx, assistant, newTab.pendingWorkspace)
244
+ return
355
245
  }
356
- if (pending) renameWorkspaceFromLaunch(ctx, pending, assistant)
246
+ // Normalized to '' so "is there a prompt" stays one comparison.
247
+ launchWithPrompt(ctx, assistant, newTab?.pendingPrompt ?? '', undefined)
357
248
  return
358
249
  }
359
250
  case 'edit-selected-assistant': {
@@ -389,18 +280,10 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
389
280
  const projectId = state.currentProjectId
390
281
  if (!(projectId != null && projectId !== '')) return
391
282
  const { baseRef, prompt } = state.modal
392
- void (async () => {
393
- try {
394
- await enqueueGitOp(async () =>
395
- createWorkspaceFromModal(ctx, projectId, {
396
- baseRef: baseRef !== '' ? baseRef : undefined,
397
- prompt,
398
- })
399
- )
400
- } catch (error) {
401
- toast.error(error instanceof Error ? error.message : String(error))
402
- }
403
- })()
283
+ startWorkspaceCreation(ctx, projectId, {
284
+ baseRef: baseRef !== '' ? baseRef : undefined,
285
+ prompt,
286
+ })
404
287
  return
405
288
  }
406
289
  case 'confirm-selected-project': {
@@ -4,6 +4,7 @@ import type { SideEffectContext } from './side-effect-context'
4
4
  import { logInputDebug } from '../debug/input-log'
5
5
  import { createPrefixedId } from '../platform/id'
6
6
  import {
7
+ assistantAcceptsPromptArg,
7
8
  getAllAssistantOptions,
8
9
  getAssistantOption,
9
10
  isCommandAvailable,
@@ -20,6 +21,7 @@ import {
20
21
  } from '../state/layout-tree'
21
22
  import { getActiveWorkspace, getCurrentProject } from '../state/project-workspaces'
22
23
  import { createDefaultTerminalModes } from '../state/terminal-modes'
24
+ import { injectPromptWhenReady } from './prompt-injection'
23
25
  import { getSelectedAssistantOption } from './selection'
24
26
 
25
27
  /**
@@ -171,6 +173,40 @@ export function launchAssistant(
171
173
  return tab.id
172
174
  }
173
175
 
176
+ /**
177
+ * Spawn the assistant and get `prompt` in front of it.
178
+ *
179
+ * Handed over at spawn where the CLI takes one. Pasting it into a live TUI
180
+ * works — it is what the `<C-p>` flow did — but it means polling for readiness,
181
+ * probing the screen, and retrying. An argv slot has none of those failure
182
+ * modes. Deciding it here means the prompt cannot be built twice, from two
183
+ * different reads of the store.
184
+ */
185
+ export function launchWithPrompt(
186
+ ctx: SideEffectContext,
187
+ assistant: AssistantId,
188
+ prompt: string,
189
+ workspaceId: string | undefined
190
+ ): void {
191
+ const atSpawn = prompt !== '' && assistantAcceptsPromptArg(assistant, ctx.state.customCommands)
192
+ logInputDebug('app.launchSelectedAssistant', {
193
+ assistant,
194
+ chained: workspaceId != null,
195
+ promptAtSpawn: atSpawn,
196
+ promptLength: prompt.length,
197
+ })
198
+
199
+ const tabId = launchAssistant(ctx, assistant, workspaceId, atSpawn ? [prompt] : undefined)
200
+ if (prompt !== '' && !atSpawn) {
201
+ void injectPromptWhenReady({
202
+ backend: ctx.backend,
203
+ getState: ctx.getState,
204
+ prompt,
205
+ tabId,
206
+ })
207
+ }
208
+ }
209
+
174
210
  export function getTabProjectPath(
175
211
  ctx: SideEffectContext,
176
212
  tab: Pick<TabSession, 'workspaceId'>
@@ -16,6 +16,7 @@ import {
16
16
  pruneGitWorktrees,
17
17
  removeGitWorktree,
18
18
  } from '../git/worktree'
19
+ import { copyWorktreeFiles } from '../git/worktree-files'
19
20
  import { createPrefixedId } from '../platform/id'
20
21
  import {
21
22
  assertSafeAimuxWorktreePath,
@@ -23,7 +24,7 @@ import {
23
24
  makeWorktreePath,
24
25
  sanitizePathSegment,
25
26
  } from '../platform/worktree-paths'
26
- import { shouldRefreshBase } from '../settings/flags'
27
+ import { shouldRefreshBase, worktreeCopyPatterns } from '../settings/flags'
27
28
  import { saveProjectCatalog } from '../state/project-catalog'
28
29
  import { pruneSnapshotOfWorkspace } from '../state/project-persistence'
29
30
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
@@ -93,14 +94,27 @@ function normalizeBranchName(branch: string | undefined): string | undefined {
93
94
  export async function createAimuxTempWorkspace(
94
95
  ctx: SideEffectContext,
95
96
  projectId: string,
96
- requestedName?: string,
97
- requestedBranchName?: string,
98
- requestedBaseRef?: string,
99
- sourceWorkspaceId?: string
97
+ options: {
98
+ name?: string
99
+ branchName?: string
100
+ baseRef?: string
101
+ sourceWorkspaceId?: string
102
+ /**
103
+ * Pre-allocated by callers that must name the workspace before git is done
104
+ * cutting it — the `<C-p>` chain pins a tab to it while it is still being
105
+ * created.
106
+ */
107
+ workspaceId?: string
108
+ } = {}
100
109
  ): Promise<WorkspaceRecord | undefined> {
110
+ const {
111
+ baseRef: requestedBaseRef,
112
+ branchName: requestedBranchName,
113
+ name: requestedName,
114
+ } = options
101
115
  const project = ctx.state.projects.find((entry) => entry.id === projectId)
102
116
  const source =
103
- project?.workspaces?.find((entry) => entry.id === sourceWorkspaceId) ??
117
+ project?.workspaces?.find((entry) => entry.id === options.sourceWorkspaceId) ??
104
118
  getActiveWorkspace(project)
105
119
  const sourcePath = source?.path ?? getActiveWorkspacePath(project)
106
120
  if (!project || !(sourcePath != null && sourcePath !== '')) return undefined
@@ -110,7 +124,7 @@ export async function createAimuxTempWorkspace(
110
124
  const repoRoot = (await getMainWorktreeRoot(sourcePath)) ?? source?.repoRoot ?? sourcePath
111
125
  const baseBranch = (await getCurrentBranch(sourcePath)) ?? source?.branch ?? 'HEAD'
112
126
  const baseRef = requestedBaseRef ?? baseBranch
113
- const workspaceId = createPrefixedId('workspace')
127
+ const workspaceId = options.workspaceId ?? createPrefixedId('workspace')
114
128
  const trimmedName = requestedName?.trim()
115
129
  const workspaceName =
116
130
  trimmedName != null && trimmedName !== ''
@@ -120,7 +134,11 @@ export async function createAimuxTempWorkspace(
120
134
  const branchName =
121
135
  trimmedBranch != null && trimmedBranch !== ''
122
136
  ? trimmedBranch
123
- : `aimux/${sanitizePathSegment(workspaceName, 40)}-${Date.now().toString(36)}`
137
+ : // Placeholder only: the model replaces it with a conventional
138
+ // `<type>/<subject>` branch seconds later. Kept in the `aimux/`
139
+ // namespace and lowercased so what survives a failed generation still
140
+ // reads as a throwaway branch rather than a shouted prompt.
141
+ `aimux/${sanitizePathSegment(workspaceName, 40).toLowerCase()}-${Date.now().toString(36)}`
124
142
  const targetPath = makeWorktreePath({ repoRoot, workspaceId, workspaceName })
125
143
 
126
144
  const existingWorkspace = (await listGitWorktrees(repoRoot)).find(
@@ -147,6 +165,7 @@ export async function createAimuxTempWorkspace(
147
165
  repoPath: repoRoot,
148
166
  targetPath,
149
167
  })
168
+ await copyWorktreeFiles(repoRoot, targetPath, worktreeCopyPatterns())
150
169
  const now = new Date().toISOString()
151
170
 
152
171
  const workspace: WorkspaceRecord = {
@@ -209,12 +228,15 @@ export async function runDeleteWorkspace(
209
228
 
210
229
  const repoPath = resolveWorkspaceGitDir(project, workspace)
211
230
  const isAimuxTemp = workspace.source === 'aimux-temp' && workspace.createdByAimux
212
- // Drop the throwaway aimux branch alongside the workspace so deleted temp
213
- // workspaces don't accumulate in the repo or haunt the base picker. Scoped to
214
- // the `aimux/` namespace (matches the picker filter); best-effort.
231
+ // Drop the throwaway branch alongside the workspace so deleted temp
232
+ // workspaces don't accumulate in the repo. Scoped by the record rather than by
233
+ // a name prefix: auto-naming renames the branch to a conventional
234
+ // `<type>/<subject>`, indistinguishable from a hand-made one, and
235
+ // `aimux-temp` + `createdByAimux` is exactly "aimux created this branch".
236
+ // Best-effort; git refuses while another worktree still has it checked out.
215
237
  const cleanupAimuxBranch = async (): Promise<void> => {
216
238
  const branch = workspace.branch
217
- if (isAimuxTemp && branch != null && branch !== '' && branch.startsWith('aimux/')) {
239
+ if (isAimuxTemp && branch != null && branch !== '') {
218
240
  await deleteGitBranch(repoPath, branch)
219
241
  }
220
242
  }
@@ -0,0 +1,135 @@
1
+ import type { AssistantId, PendingWorkspaceLaunch, WorkspaceRecord } from '../state/types'
2
+ import type { SideEffectContext } from './side-effect-context'
3
+
4
+ import { enqueueGitOp } from '../git/command-queue'
5
+ import { createPrefixedId } from '../platform/id'
6
+ import { hasSetupScript } from '../state/project-data'
7
+ import { findWorkspace } from '../state/project-workspaces'
8
+ import { toast } from '../state/toast-store'
9
+ import { findSetupTab } from './setup-actions'
10
+ import { launchWithPrompt } from './tab-actions'
11
+ import { createAimuxTempWorkspace } from './workspace-actions'
12
+ import { placeholderWorkspaceName, renameWorkspaceFromPrompt } from './workspace-naming'
13
+
14
+ /**
15
+ * The `<C-p>` worktree being cut while its assistant picker is already open.
16
+ * A barrier, not a value: what the launch needs to know — did the worktree
17
+ * land — is read back from the store, which is the only thing that can still
18
+ * be true a tick later.
19
+ *
20
+ * Single-slot: only one create-workspace modal can be open, so only one chain
21
+ * can ever be in flight.
22
+ */
23
+ let pendingWorkspaceCreate: Promise<void> | null = null
24
+
25
+ /**
26
+ * The `<C-p>` flow: chain into the new-tab modal *now* and cut the worktree in
27
+ * the background. `git fetch` + `worktree add` take seconds, and the next
28
+ * question — which assistant — does not depend on either, so making the user
29
+ * watch a frozen modal buys nothing. `launchPendingWorkspace` waits this out,
30
+ * so the tab still lands in a finished workspace.
31
+ */
32
+ export function startWorkspaceCreation(
33
+ ctx: SideEffectContext,
34
+ projectId: string,
35
+ params: {
36
+ prompt: string
37
+ baseRef?: string
38
+ }
39
+ ): void {
40
+ // Allocated here rather than by the create: the picker below pins its tab to
41
+ // this workspace before git has produced anything to pin to.
42
+ const workspaceId = createPrefixedId('workspace')
43
+ pendingWorkspaceCreate = (async () => {
44
+ try {
45
+ await enqueueGitOp(async () =>
46
+ // A name derived locally from the prompt, so the sidebar reads right from
47
+ // the first frame. The model-generated one replaces it a few seconds later.
48
+ // The branch is left to `createAimuxTempWorkspace`, which suffixes it with
49
+ // a timestamp: two workspaces started from the same prompt must not
50
+ // collide on the branch name before the model has distinguished them.
51
+ createAimuxTempWorkspace(ctx, projectId, {
52
+ baseRef: params.baseRef,
53
+ name: placeholderWorkspaceName(params.prompt),
54
+ workspaceId,
55
+ })
56
+ )
57
+ } catch (error) {
58
+ // The modal that would have shown this inline is already gone, so the
59
+ // toast is the only channel left. The launch reads the missing record.
60
+ toast.error(error instanceof Error ? error.message : String(error))
61
+ }
62
+ })()
63
+ ctx.dispatch({ type: 'close-modal' })
64
+ ctx.dispatch({
65
+ pendingWorkspace: { projectId, prompt: params.prompt, workspaceId },
66
+ type: 'open-new-tab-modal',
67
+ })
68
+ }
69
+
70
+ /**
71
+ * Setup runs concurrently with the agent by design, so say so rather than
72
+ * letting the agent run tests against a half-installed tree and draw the wrong
73
+ * conclusion. Only prefixed when a setup is actually live.
74
+ */
75
+ function buildWorkspacePrompt(ctx: SideEffectContext, pending: PendingWorkspaceLaunch): string {
76
+ const setupTab = findSetupTab(ctx.getState().tabs, pending.workspaceId)
77
+ // No setup tab yet does not mean no setup: the workspace can land in the
78
+ // store the same tick the user picks, and the runner only spawns on the next
79
+ // render. A project with a script is about to run it, so say so.
80
+ const setupRunning = setupTab ? setupTab.status === 'running' : hasSetupScript(pending.projectId)
81
+ if (!setupRunning) return pending.prompt
82
+ return `Note: a setup script is currently installing this workspace's dependencies in the background. Wait for it to finish before running builds, tests, or anything that reads installed dependencies.\n\n${pending.prompt}`
83
+ }
84
+
85
+ /**
86
+ * Name the workspace after what its prompt describes, using the assistant the
87
+ * user just picked. Background work that must never block or fail the launch.
88
+ *
89
+ * Always `pending.prompt`, never the setup-annotated variant built for the
90
+ * agent — the note is guidance, not part of what the user asked for.
91
+ */
92
+ function renameWorkspaceFromLaunch(
93
+ ctx: SideEffectContext,
94
+ pending: PendingWorkspaceLaunch,
95
+ workspace: WorkspaceRecord,
96
+ assistant: AssistantId
97
+ ): void {
98
+ void renameWorkspaceFromPrompt(
99
+ { projectId: pending.projectId, prompt: pending.prompt, provider: assistant, workspace },
100
+ {
101
+ applyName: (projectId, workspaceId, patch) =>
102
+ ctx.dispatch({ patch, projectId, type: 'update-workspace-record', workspaceId }),
103
+ }
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Pin the tab to the workspace `<C-p>` is cutting, hand it the prompt, and name
109
+ * the workspace after what it describes.
110
+ *
111
+ * Waits for the worktree first: spawning early would drop the tab in the
112
+ * project checkout instead, since that is what an unresolvable workspace id
113
+ * falls back to.
114
+ */
115
+ export function launchPendingWorkspace(
116
+ ctx: SideEffectContext,
117
+ assistant: AssistantId,
118
+ pending: PendingWorkspaceLaunch
119
+ ): void {
120
+ const creating = pendingWorkspaceCreate
121
+ pendingWorkspaceCreate = null
122
+ void (async () => {
123
+ await creating
124
+ // Read the store again: the record landed after `ctx.state` was captured,
125
+ // and its absence is how a failed create says "do not spawn".
126
+ const fresh = { ...ctx, state: ctx.getState() }
127
+ const found = findWorkspace(fresh.state.projects, pending.workspaceId)
128
+ if (!found) {
129
+ toast.error('Workspace creation failed — no tab was opened')
130
+ return
131
+ }
132
+ launchWithPrompt(fresh, assistant, buildWorkspacePrompt(fresh, pending), pending.workspaceId)
133
+ renameWorkspaceFromLaunch(fresh, pending, found.workspace, assistant)
134
+ })()
135
+ }
@@ -5,24 +5,22 @@
5
5
  // the generated one replaces it in place when it arrives. The heuristic result
6
6
  // is not a stopgap to be embarrassed about — when no headless CLI is installed
7
7
  // it is the final name, and it is still derived from what the user asked for.
8
+ //
9
+ // The branch is not a slug of that name. A tab title belongs to the user and
10
+ // speaks their language; a branch name is read by git, by reviewers and by CI,
11
+ // so the model is asked for it separately, in English and under a
12
+ // conventional-commit type. A branch is only renamed when the model returns one
13
+ // in that shape — the placeholder is a better outcome than a bad convention.
8
14
 
9
15
  import type { AssistantId, WorkspaceRecord } from '../state/types'
10
16
 
11
17
  import { heuristicTitle } from '../auto-rename/heuristic-title'
12
- import { generateTabTitle, type TitleSpawnFn } from '../auto-rename/title-runner'
18
+ import { generateWorkspaceNaming, type TitleSpawnFn } from '../auto-rename/title-runner'
13
19
  import { renameGitBranch } from '../git/worktree'
14
- import { sanitizePathSegment } from '../platform/worktree-paths'
15
20
 
16
21
  /** Model naming is best-effort background work; it must never outlive the app. */
17
22
  const NAMING_TIMEOUT_MS = 30_000
18
23
 
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
24
  /**
27
25
  * The name a workspace carries until the model answers. Undefined when the
28
26
  * prompt yields nothing usable, which lets `createAimuxTempWorkspace` fall back
@@ -60,7 +58,7 @@ export async function renameWorkspaceFromPrompt(
60
58
  target: WorkspaceNamingTarget,
61
59
  deps: WorkspaceNamingDeps
62
60
  ): Promise<void> {
63
- const result = await generateTabTitle({
61
+ const result = await generateWorkspaceNaming({
64
62
  firstPrompt: target.prompt,
65
63
  provider: target.provider,
66
64
  signal: deps.signal ?? new AbortController().signal,
@@ -70,21 +68,24 @@ export async function renameWorkspaceFromPrompt(
70
68
  if (result.status !== 'ok') return
71
69
 
72
70
  const { workspace } = target
73
- if (result.title === workspace.name) return
74
-
75
- const branch = branchNameFor(result.title)
71
+ const { branch } = result
76
72
  const rename = deps.renameBranch ?? renameGitBranch
77
73
  // Renamed from inside the workspace, not the main checkout: the branch is
78
74
  // that worktree's current branch, which is the form git always accepts.
79
75
  // Renaming first means a refusal (the name is taken) leaves the record
80
76
  // 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
77
+ const renamedBranch =
78
+ branch != null &&
79
+ workspace.branch != null &&
80
+ workspace.branch !== '' &&
81
+ branch !== workspace.branch &&
82
+ (await rename(workspace.path, workspace.branch, branch))
83
+ ? branch
84
+ : undefined
85
85
 
86
+ if (result.title === workspace.name && renamedBranch == null) return
86
87
  deps.applyName(target.projectId, workspace.id, {
87
88
  name: result.title,
88
- ...(branchRenamed ? { branch } : null),
89
+ ...(renamedBranch == null ? null : { branch: renamedBranch }),
89
90
  })
90
91
  }
package/src/app.tsx CHANGED
@@ -82,12 +82,18 @@ export function App({
82
82
  resolvedConfig: ResolvedConfig
83
83
  userConfig: AimuxUserConfig
84
84
  }) {
85
- // Publish the auto-commit enabled flag before any children render so
86
- // actions (which live outside React) can read it synchronously.
87
- setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
88
- setMultiRepoConfig(resolvedConfig.multiRepo)
89
- setExternalEditorConfig(resolvedConfig.externalEditor)
90
- setStatusBarSeparator(resolvedConfig.statusBar?.separator)
85
+ // Publish the config-file baseline into the runtime singletons before any
86
+ // children render, so actions (which live outside React) can read them
87
+ // synchronously. Lazy initializer, not the render body: this component
88
+ // re-renders on every dispatch, and re-running these would overwrite what the
89
+ // settings screen has since applied on top of the baseline (`hydrateSettings`).
90
+ useState(() => {
91
+ setAutoCommitEnabled(resolvedConfig.autoCommit.enabled)
92
+ setMultiRepoConfig(resolvedConfig.multiRepo)
93
+ setExternalEditorConfig(resolvedConfig.externalEditor)
94
+ setStatusBarSeparator(resolvedConfig.statusBar?.separator)
95
+ return null
96
+ })
91
97
 
92
98
  const keymapHandlers = useMemo(
93
99
  () => {