@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 +1 -1
- package/src/app-runtime/side-effects.ts +15 -132
- package/src/app-runtime/tab-actions.ts +36 -0
- package/src/app-runtime/workspace-actions.ts +34 -12
- package/src/app-runtime/workspace-launch.ts +135 -0
- package/src/app-runtime/workspace-naming.ts +19 -18
- package/src/app.tsx +12 -6
- package/src/auto-rename/title-runner.ts +105 -15
- package/src/cli/commands/workspace/create-core.ts +3 -1
- package/src/git/pr-status-poller.ts +5 -3
- package/src/git/pr-status.ts +28 -2
- package/src/git/workspace-divergence-poller.ts +21 -14
- package/src/git/worktree-files.ts +51 -0
- package/src/git/worktree.ts +9 -0
- package/src/platform/worktree-paths.ts +6 -1
- package/src/settings/flags.ts +11 -1
- package/src/settings/sections/git.ts +14 -0
- package/src/state/pr-status-store.ts +15 -11
- package/src/ui/components/git/pane/pr-state-row.tsx +62 -18
- package/src/ui/components/layout/sidebar/project-list.tsx +127 -100
- package/src/ui/components/layout/terminal-pane.tsx +0 -30
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +12 -29
- package/src/ui/project-ordering.ts +18 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
317
|
-
//
|
|
318
|
-
// Otherwise
|
|
319
|
-
// launchAssistant resolves itself.
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
:
|
|
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
|
|
213
|
-
// workspaces don't accumulate in the repo
|
|
214
|
-
//
|
|
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 !== ''
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
...(
|
|
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
|
|
86
|
-
// actions (which live outside React) can read
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
() => {
|