@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.
@@ -1,4 +1,5 @@
1
1
  import { buildHeadlessInvocation, type HeadlessInvocation } from '../auto-commit/headless-commands'
2
+ import { foldDiacritics } from '../platform/worktree-paths'
2
3
  import { clampTitle } from './title-format'
3
4
 
4
5
  export type TitleSpawnFn = (
@@ -16,6 +17,33 @@ export type TitleResult =
16
17
  | { status: 'failed' }
17
18
  | { status: 'unavailable' }
18
19
 
20
+ /**
21
+ * A workspace needs two different names from one request: a tab title in the
22
+ * user's own language, and a branch name — which is read by git, by reviewers
23
+ * and by CI, so it follows the repo's conventions instead: English, a
24
+ * conventional-commit type, kebab-case. `branch` is null when the model gave
25
+ * nothing that qualifies; the caller keeps the branch it already has.
26
+ */
27
+ export type NamingResult =
28
+ | { status: 'ok'; title: string; branch: string | null }
29
+ | { status: 'failed' }
30
+ | { status: 'unavailable' }
31
+
32
+ /** Conventional-commit types a generated branch may use; anything else is refused. */
33
+ const BRANCH_TYPES = new Set([
34
+ 'build',
35
+ 'chore',
36
+ 'ci',
37
+ 'docs',
38
+ 'feat',
39
+ 'fix',
40
+ 'perf',
41
+ 'refactor',
42
+ 'style',
43
+ 'test',
44
+ ])
45
+ const BRANCH_SUBJECT_WORDS = 5
46
+
19
47
  export function buildTitlePrompt(firstPrompt: string): string {
20
48
  return [
21
49
  'Create a concise tab title for the user request below.',
@@ -26,15 +54,56 @@ export function buildTitlePrompt(firstPrompt: string): string {
26
54
  ].join('\n')
27
55
  }
28
56
 
29
- export function sanitizeGeneratedTitle(raw: string): string | null {
30
- const first = raw
57
+ export function buildWorkspaceNamingPrompt(firstPrompt: string): string {
58
+ return [
59
+ 'Name a workspace for the user request below.',
60
+ 'Return exactly two lines and nothing else.',
61
+ 'Line 1 — a tab title: 2 to 6 words, at most 48 characters, in the same language as the request.',
62
+ 'Line 2 — a git branch named <type>/<subject>, always in English whatever the language of the request.',
63
+ '<type> is one of: feat, fix, refactor, perf, docs, test, chore, ci, style, build.',
64
+ '<subject> is 2 to 5 lowercase words joined by hyphens, naming what changes rather than restating the request.',
65
+ 'Example line 2: fix/scroll-drift-on-resize',
66
+ 'No quotes, no labels, no numbering, no markdown, no ending punctuation.',
67
+ '',
68
+ firstPrompt.slice(0, 8_000),
69
+ ].join('\n')
70
+ }
71
+
72
+ function nonEmptyLines(raw: string): string[] {
73
+ return raw
31
74
  .split(/\r?\n/u)
32
75
  .map((line) => line.trim())
33
- .find(Boolean)
34
- if (first == null || first === '') return null
76
+ .filter(Boolean)
77
+ }
78
+
79
+ /** Strip the wrappers a model reaches for even when told not to: labels, list markers, quotes. */
80
+ function unwrapLine(line: string, label: RegExp): string {
81
+ return line
82
+ .replace(label, '')
83
+ .replace(/^(?:\d+[.)]|[-*])\s*/u, '')
84
+ .replaceAll(/^["'`“”‘’]+|["'`“”‘’]+$/gu, '')
85
+ }
35
86
 
36
- const unlabelled = first.replace(/^TITLE\s*:\s*/iu, '').replaceAll(/^["'“”‘’]+|["'“”‘’]+$/gu, '')
37
- return clampTitle(unlabelled)
87
+ export function sanitizeGeneratedTitle(raw: string): string | null {
88
+ const first = nonEmptyLines(raw)[0]
89
+ if (first == null) return null
90
+ return clampTitle(unwrapLine(first, /^TITLE\s*:\s*/iu))
91
+ }
92
+
93
+ export function sanitizeGeneratedBranch(raw: string): string | null {
94
+ const line = unwrapLine(raw.trim(), /^BRANCH\s*:\s*/iu)
95
+ const slash = line.indexOf('/')
96
+ if (slash < 0) return null
97
+ const type = line.slice(0, slash).trim().toLowerCase()
98
+ if (!BRANCH_TYPES.has(type)) return null
99
+ // Whole words only: a mid-word cut ("...-bran") names nothing.
100
+ const subject = foldDiacritics(line.slice(slash + 1))
101
+ .toLowerCase()
102
+ .split(/[^a-z0-9]+/u)
103
+ .filter(Boolean)
104
+ .slice(0, BRANCH_SUBJECT_WORDS)
105
+ .join('-')
106
+ return subject === '' ? null : `${type}/${subject}`
38
107
  }
39
108
 
40
109
  function executableOnPath(executable: string): boolean {
@@ -46,7 +115,7 @@ function executableOnPath(executable: string): boolean {
46
115
  }
47
116
  }
48
117
 
49
- export async function generateTabTitle(options: {
118
+ export interface NamingOptions {
50
119
  provider: string
51
120
  model?: string
52
121
  firstPrompt: string
@@ -54,12 +123,13 @@ export async function generateTabTitle(options: {
54
123
  signal: AbortSignal
55
124
  spawn?: TitleSpawnFn
56
125
  isExecutableAvailable?: (executable: string) => boolean
57
- }): Promise<TitleResult> {
58
- const invocation = buildHeadlessInvocation(
59
- options.provider,
60
- buildTitlePrompt(options.firstPrompt),
61
- options.model
62
- )
126
+ }
127
+
128
+ async function runNamingModel(
129
+ options: NamingOptions,
130
+ prompt: string
131
+ ): Promise<{ status: 'ok'; stdout: string } | { status: 'failed' } | { status: 'unavailable' }> {
132
+ const invocation = buildHeadlessInvocation(options.provider, prompt, options.model)
63
133
  if (!invocation) return { status: 'unavailable' }
64
134
 
65
135
  // A caller-supplied spawn does not go through PATH, so only probe it for the
@@ -72,13 +142,33 @@ export async function generateTabTitle(options: {
72
142
  try {
73
143
  const result = await (options.spawn ?? defaultSpawn)(invocation, signal)
74
144
  if (!result || result.exitCode !== 0 || signal.aborted) return { status: 'failed' }
75
- const title = sanitizeGeneratedTitle(result.stdout)
76
- return title == null ? { status: 'failed' } : { status: 'ok', title }
145
+ return { status: 'ok', stdout: result.stdout }
77
146
  } catch {
78
147
  return { status: 'failed' }
79
148
  }
80
149
  }
81
150
 
151
+ export async function generateTabTitle(options: NamingOptions): Promise<TitleResult> {
152
+ const run = await runNamingModel(options, buildTitlePrompt(options.firstPrompt))
153
+ if (run.status !== 'ok') return run
154
+ const title = sanitizeGeneratedTitle(run.stdout)
155
+ return title == null ? { status: 'failed' } : { status: 'ok', title }
156
+ }
157
+
158
+ /** One model call for both names — a workspace must not wait on two. */
159
+ export async function generateWorkspaceNaming(options: NamingOptions): Promise<NamingResult> {
160
+ const run = await runNamingModel(options, buildWorkspaceNamingPrompt(options.firstPrompt))
161
+ if (run.status !== 'ok') return run
162
+ const [titleLine, branchLine] = nonEmptyLines(run.stdout)
163
+ const title = titleLine == null ? null : sanitizeGeneratedTitle(titleLine)
164
+ if (title == null) return { status: 'failed' }
165
+ return {
166
+ branch: branchLine == null ? null : sanitizeGeneratedBranch(branchLine),
167
+ status: 'ok',
168
+ title,
169
+ }
170
+ }
171
+
82
172
  async function defaultSpawn(
83
173
  invocation: HeadlessInvocation,
84
174
  signal: AbortSignal
@@ -2,6 +2,7 @@ import type { ProjectRecord, WorkspaceRecord } from '../../../state/types'
2
2
  import type { DaemonClient } from '../../client/daemon-client'
3
3
 
4
4
  import { createGitWorktree, removeGitWorktree, resolveGitRef } from '../../../git/worktree'
5
+ import { copyWorktreeFiles } from '../../../git/worktree-files'
5
6
  import { IPC_CAPABILITY_WORKSPACE_LIFECYCLE_EVENTS } from '../../../ipc/protocol'
6
7
  import { createPrefixedId } from '../../../platform/id'
7
8
  import {
@@ -10,7 +11,7 @@ import {
10
11
  makeWorktreePath,
11
12
  pruneEmptyWorktreeParent,
12
13
  } from '../../../platform/worktree-paths'
13
- import { shouldRefreshBase } from '../../../settings/flags'
14
+ import { shouldRefreshBase, worktreeCopyPatterns } from '../../../settings/flags'
14
15
 
15
16
  export interface CreateWorkspaceParams {
16
17
  /** Base ref for the branch (callers default to 'HEAD'). */
@@ -84,6 +85,7 @@ export async function createProjectWorkspace(
84
85
  await pruneEmptyWorktreeParent(targetPath)
85
86
  throw error
86
87
  }
88
+ await copyWorktreeFiles(primary.repoRoot, targetPath, worktreeCopyPatterns())
87
89
 
88
90
  const now = new Date().toISOString()
89
91
  const record: WorkspaceRecord = {
@@ -15,14 +15,16 @@ interface Options {
15
15
 
16
16
  /** One-shot refetch, for when an action we took just invalidated the state. */
17
17
  export async function refreshPrStatus(projectPath: string): Promise<void> {
18
- prStatusStore.getState().setResult(await collectPrStatus(projectPath))
18
+ prStatusStore.getState().setResult(projectPath, await collectPrStatus(projectPath))
19
19
  }
20
20
 
21
21
  export function usePrStatusPolling({ enabled, projectPath }: Options): void {
22
22
  useEffect(() => {
23
23
  if (!enabled || !(projectPath != null && projectPath !== '')) return
24
24
 
25
- prStatusStore.getState().reset()
25
+ // Show what we last knew about this path while the fetch runs in the
26
+ // background, rather than blanking the row on every workspace switch.
27
+ prStatusStore.getState().selectPath(projectPath)
26
28
 
27
29
  let cancelled = false
28
30
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -36,7 +38,7 @@ export function usePrStatusPolling({ enabled, projectPath }: Options): void {
36
38
  const tick = async () => {
37
39
  const result = await collectPrStatus(projectPath)
38
40
  if (cancelled) return
39
- prStatusStore.getState().setResult(result)
41
+ prStatusStore.getState().setResult(projectPath, result)
40
42
  if (result.kind === 'error') {
41
43
  delay = Math.min(delay * 2, MAX_INTERVAL_MS)
42
44
  } else if (result.kind === 'ok' && result.checks.some((c) => c.state === 'pending')) {
@@ -145,7 +145,7 @@ export function parsePrView(raw: unknown): PrStatusResult {
145
145
  }
146
146
  }
147
147
 
148
- export type PrAction = 'merge' | null
148
+ export type PrAction = 'cleanup' | 'merge' | null
149
149
 
150
150
  export interface PrActionState {
151
151
  label: string
@@ -160,7 +160,11 @@ export interface PrActionState {
160
160
  * offer an action for still gets an honest label rather than a dead button.
161
161
  */
162
162
  export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
163
- if (pr.state === 'MERGED') return { action: null, label: 'Merged', tone: 'neutral' }
163
+ // Merged is the one terminal state with something left to do: the workspace
164
+ // that carried the branch is now dead weight. The row offers the removal
165
+ // right where the merge happened; whether it's actually removable (not the
166
+ // primary workspace) is the caller's call, not the PR's.
167
+ if (pr.state === 'MERGED') return { action: 'cleanup', label: 'Merged', tone: 'ok' }
164
168
  if (pr.state === 'CLOSED') return { action: null, label: 'Closed', tone: 'blocked' }
165
169
  if (pr.isDraft) return { action: null, label: 'Draft', tone: 'neutral' }
166
170
  if (pr.mergeable === 'CONFLICTING') {
@@ -182,6 +186,28 @@ export function prActionState(pr: PrSummary, checks: PrCheck[]): PrActionState {
182
186
  return { action: null, label: 'Checking…', tone: 'neutral' }
183
187
  }
184
188
 
189
+ export type PrCleanupKind = 'branch' | 'worktree' | null
190
+
191
+ /**
192
+ * What "clean up" means for a merged PR, which depends on where its branch
193
+ * lives. A linked workspace is disposable, so it goes. The repo checkout is not
194
+ * — the equivalent is leaving the merged branch for the one it landed on, which
195
+ * the PR names, so a PR into `develop` doesn't drop you on `main`.
196
+ *
197
+ * `gh pr view` resolves the PR from the checked-out branch, so a PR on screen
198
+ * means we're on its head — but a base already equal to it has nowhere to go.
199
+ */
200
+ export function prCleanupKind(
201
+ action: PrAction,
202
+ pr: Pick<PrSummary, 'base' | 'head'>,
203
+ workspaceIsRemovable: boolean
204
+ ): PrCleanupKind {
205
+ if (action !== 'cleanup') return null
206
+ if (workspaceIsRemovable) return 'worktree'
207
+ if (pr.base === '' || pr.base === pr.head) return null
208
+ return 'branch'
209
+ }
210
+
185
211
  export interface ClampedBody {
186
212
  text: string
187
213
  truncated: boolean
@@ -9,22 +9,24 @@ import { getBranchDivergence, getWorkspaceDiffStat } from './divergence'
9
9
  const INTERVAL_MS = 4000
10
10
 
11
11
  // Polls per-workspace base divergence for the current project while enabled and
12
- // dispatches it into workspaceDivergence. Only workspaces that record a baseRef
13
- // (the aimux-created ones) are measured; the primary and externally-discovered
14
- // workspaces have no recorded base and are left out.
12
+ // dispatches it into workspaceDivergence. aimux-created workspaces are measured
13
+ // against the ref they forked from; the primary and externally-discovered ones
14
+ // never forked, so they fall back to their own upstream — for a root checkout on
15
+ // main that reads as "unpushed commits + dirty work". A branch with no upstream
16
+ // makes git fail, which the poller already renders as nothing.
15
17
  export function useWorkspaceDivergencePolling(enabled: boolean): void {
16
- const currentProjectId = useAppStore((s) => s.currentProjectId)
17
18
  const projects = useAppStore((s) => s.projects)
18
19
 
19
20
  useEffect(() => {
20
21
  if (!enabled) return
21
- const project =
22
- currentProjectId != null && currentProjectId !== ''
23
- ? projects.find((s) => s.id === currentProjectId)
24
- : undefined
25
- const targets = (project?.workspaces ?? []).filter(
26
- (w) => w.baseRef != null && w.baseRef !== '' && w.branch != null && w.branch !== ''
27
- )
22
+ // Every project, not just the current one: each dispatch replaces the whole
23
+ // map, so polling one project's workspaces blanks the stats of every other
24
+ // project's rows which the sidebar shows all of at once.
25
+ // ponytail: one unbounded fan-out per tick, two `git` spawns per workspace.
26
+ // Batch or stagger if a machine with many projects feels it.
27
+ const targets = projects
28
+ .flatMap((project) => project.workspaces ?? [])
29
+ .filter((w) => w.branch != null && w.branch !== '')
28
30
  if (targets.length === 0) return
29
31
 
30
32
  let cancelled = false
@@ -33,9 +35,14 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
33
35
  const tick = async () => {
34
36
  const entries = await Promise.all(
35
37
  targets.map(async (workspace) => {
36
- const base = workspace.baseRef
37
38
  const branch = workspace.branch
38
- if (base == null || branch == null) return null
39
+ if (branch == null) return null
40
+ // `<branch>@{upstream}` rather than a bare `@{upstream}`: the latter
41
+ // resolves against the repo root's HEAD, which is not this workspace.
42
+ const base =
43
+ workspace.baseRef != null && workspace.baseRef !== ''
44
+ ? workspace.baseRef
45
+ : `${branch}@{upstream}`
39
46
  // Commits come from the repo root (comparing two refs); lines come
40
47
  // from the workspace itself, so uncommitted work is counted too.
41
48
  const [divergence, stat] = await Promise.all([
@@ -61,5 +68,5 @@ export function useWorkspaceDivergencePolling(enabled: boolean): void {
61
68
  cancelled = true
62
69
  if (timer != null) clearTimeout(timer)
63
70
  }
64
- }, [enabled, currentProjectId, projects])
71
+ }, [enabled, projects])
65
72
  }
@@ -0,0 +1,51 @@
1
+ import { Glob } from 'bun'
2
+ import { existsSync } from 'node:fs'
3
+ import { cp, mkdir } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ import { logDebug } from '../debug/input-log'
7
+
8
+ /**
9
+ * Seed a fresh worktree with the untracked local files it needs to run.
10
+ *
11
+ * A worktree checkout holds tracked files and nothing else, so every ignored
12
+ * local file — `.env` above all — is missing the moment the workspace opens.
13
+ * The setup script can recreate some of them from a template; it cannot invent
14
+ * the secrets, which is why they are copied from the main checkout instead.
15
+ *
16
+ * ponytail: the patterns are matched against the whole checkout, gitignore and
17
+ * all — a recursive pattern walks `node_modules` too. Feeding the scan the ignore
18
+ * rules is the upgrade path if that ever costs anything noticeable.
19
+ *
20
+ * Best-effort by design: a pattern that matches nothing, or a file that cannot
21
+ * be read, must not take the workspace down with it. Nothing is ever
22
+ * overwritten — a match that already exists in the new worktree is tracked
23
+ * content, and clobbering it would dirty the workspace before its first commit.
24
+ */
25
+ export async function copyWorktreeFiles(
26
+ repoPath: string,
27
+ targetPath: string,
28
+ patterns: readonly string[]
29
+ ): Promise<void> {
30
+ for (const pattern of patterns) {
31
+ try {
32
+ const matches = new Glob(pattern).scan({ cwd: repoPath, dot: true, onlyFiles: true })
33
+ for await (const relative of matches) {
34
+ const to = join(targetPath, relative)
35
+ if (existsSync(to)) continue
36
+ await mkdir(dirname(to), { recursive: true })
37
+ await cp(join(repoPath, relative), to)
38
+ }
39
+ } catch (error) {
40
+ // A workspace without its .env still opens; a workspace that failed to be
41
+ // created does not. Logged rather than swallowed outright: a file that
42
+ // silently never arrives is otherwise indistinguishable from one the
43
+ // pattern never matched.
44
+ logDebug('worktree.seed.error', {
45
+ error: error instanceof Error ? error.message : String(error),
46
+ pattern,
47
+ repoPath,
48
+ })
49
+ }
50
+ }
51
+ }
@@ -181,6 +181,15 @@ export async function pruneGitWorktrees(repoPath: string): Promise<void> {
181
181
  await $`git -C ${repoPath} worktree prune`.quiet().nothrow()
182
182
  }
183
183
 
184
+ // Switch a checkout to an existing branch. Throws git's own message rather than
185
+ // a paraphrase: "local changes would be overwritten" is the one thing the user
186
+ // needs to read, and no wording of ours beats it.
187
+ export async function checkoutBranch(cwd: string, branch: string): Promise<void> {
188
+ const result = await $`git -C ${cwd} checkout ${branch}`.quiet().nothrow()
189
+ if (result.exitCode === 0) return
190
+ throw new Error(result.stderr.toString().trim() || `failed to check out ${branch}`)
191
+ }
192
+
184
193
  // Drop every `aimux/` branch left behind by deleted temp worktrees. git refuses
185
194
  // to delete branches still checked out in a live worktree, so this only removes
186
195
  // true orphans. Returns the number of branches removed.
@@ -21,8 +21,13 @@ export function getAimuxWorktreeRoot(): string {
21
21
  return root != null && root !== '' ? root : defaultWorktreeRoot()
22
22
  }
23
23
 
24
+ /** `améliorer` → `ameliorer`, so accents slug as letters instead of separators. */
25
+ export function foldDiacritics(input: string): string {
26
+ return input.normalize('NFD').replaceAll(/\p{Diacritic}/gu, '')
27
+ }
28
+
24
29
  export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
25
- const sanitized = input
30
+ const sanitized = foldDiacritics(input)
26
31
  .trim()
27
32
  .replaceAll(/[^A-Za-z0-9._-]+/g, '-')
28
33
  .replaceAll(/\.\.+/g, '-')
@@ -1,5 +1,5 @@
1
1
  import { loadConfig } from '../config'
2
- import { FETCH_BASE } from './sections/git'
2
+ import { COPY_FILES, COPY_FILES_DEFAULT, FETCH_BASE } from './sections/git'
3
3
 
4
4
  /**
5
5
  * Settings read outside the screen, off the file rather than the store.
@@ -13,3 +13,13 @@ import { FETCH_BASE } from './sections/git'
13
13
  export function shouldRefreshBase(): boolean {
14
14
  return loadConfig().settings?.[FETCH_BASE] !== false
15
15
  }
16
+
17
+ /** Untracked files a new workspace is seeded with, as globs relative to the repo. */
18
+ export function worktreeCopyPatterns(): string[] {
19
+ const value = loadConfig().settings?.[COPY_FILES]
20
+ // Only an explicit empty string disables it; an untouched setting gets `.env`.
21
+ return String(value ?? COPY_FILES_DEFAULT)
22
+ .split(',')
23
+ .map((pattern) => pattern.trim())
24
+ .filter((pattern) => pattern !== '')
25
+ }
@@ -6,6 +6,8 @@ import type { SettingSection, SettingValue } from '../types'
6
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
7
7
 
8
8
  export const FETCH_BASE = 'git.fetchBase'
9
+ export const COPY_FILES = 'git.worktreeCopyFiles'
10
+ export const COPY_FILES_DEFAULT = '.env'
9
11
 
10
12
  function isFileListMode(value: SettingValue): value is GitFileListMode {
11
13
  return value === 'tree' || value === 'flat'
@@ -93,6 +95,18 @@ export const GIT_SECTION: SettingSection = {
93
95
  label: 'Refresh the base branch',
94
96
  storage: 'settings',
95
97
  },
98
+ {
99
+ // No `apply`, same reason as the row above: `worktreeCopyPatterns` reads
100
+ // this off the file so `aimux workspace create` honours it too.
101
+ description:
102
+ 'Untracked files copied into each new workspace. Globs from the repo root: **/.env for nested ones.',
103
+ fallback: COPY_FILES_DEFAULT,
104
+ id: COPY_FILES,
105
+ kind: 'text',
106
+ label: 'Seed new workspaces with',
107
+ placeholder: 'nothing',
108
+ storage: 'settings',
109
+ },
96
110
  {
97
111
  apply: (value) => setMultiRepoConfig({ ...getMultiRepoConfig(), enabled: value === true }),
98
112
  description: 'Aggregate the status of git repos nested under the project.',
@@ -5,34 +5,38 @@ import type { PrStatusResult } from '../git/pr-status'
5
5
 
6
6
  export interface PrStatusState {
7
7
  result: PrStatusResult | null
8
+ /** Last result seen per project path, so a workspace switch shows its own
9
+ * previous state instead of blanking while the background fetch runs. */
10
+ byPath: Record<string, PrStatusResult>
8
11
  /** True once a fetch failed but we are still showing the previous good result. */
9
12
  stale: boolean
10
- setResult: (result: PrStatusResult) => void
11
- reset: () => void
13
+ setResult: (path: string, result: PrStatusResult) => void
14
+ /** Point the row at a project path, showing whatever we last knew about it. */
15
+ selectPath: (path: string) => void
12
16
  }
13
17
 
14
18
  export const prStatusStore = createStore<PrStatusState>((set) => ({
15
- reset: () => set({ result: null, stale: false }),
19
+ byPath: {},
16
20
  result: null,
17
- setResult: (result: PrStatusResult) =>
21
+ selectPath: (path: string) =>
22
+ set((state) => ({ result: state.byPath[path] ?? null, stale: false })),
23
+ setResult: (path: string, result: PrStatusResult) =>
18
24
  set((state) => {
19
25
  // A transient `gh` failure shouldn't blank a PR we already resolved — keep
20
26
  // the last good snapshot and mark it stale instead (same contract as
21
27
  // ai-usage-store's setSnapshot).
22
28
  if (result.kind === 'error' && state.result?.kind === 'ok') return { stale: true }
23
- return { result, stale: false }
29
+ return { byPath: { ...state.byPath, [path]: result }, result, stale: false }
24
30
  }),
25
31
  stale: false,
26
32
  }))
27
33
 
28
34
  /**
29
- * The PR state row occupies its band both while the first fetch is in flight
30
- * and once it resolved to a PR anything else (no PR, no gh, error) gives the
31
- * row back. Shared so the header and the row itself can never disagree and
32
- * shift the layout under the user.
35
+ * The PR state row only occupies its band once we know there is a PR — an
36
+ * unknown or resolved-to-nothing state gives the row back. Shared so the header
37
+ * and the row itself can never disagree and shift the layout under the user.
33
38
  */
34
- export const selectPrRowVisible = (state: PrStatusState): boolean =>
35
- state.result === null || state.result.kind === 'ok'
39
+ export const selectPrRowVisible = (state: PrStatusState): boolean => state.result?.kind === 'ok'
36
40
 
37
41
  export function usePrStatusStore<T>(selector: (state: PrStatusState) => T): T {
38
42
  return useStore(prStatusStore, selector)
@@ -1,10 +1,15 @@
1
1
  import { memo, useCallback, useState } from 'react'
2
2
 
3
+ import { enqueueGitOp } from '../../../../git/command-queue'
3
4
  import { approveAndMergePr } from '../../../../git/pr-merge'
4
- import { type PrActionState, prActionState } from '../../../../git/pr-status'
5
+ import { type PrActionState, prActionState, prCleanupKind } from '../../../../git/pr-status'
5
6
  import { refreshPrStatus } from '../../../../git/pr-status-poller'
7
+ import { checkoutBranch } from '../../../../git/worktree'
6
8
  import { openUrl } from '../../../../platform/open-url'
9
+ import { useAppStore } from '../../../../state/app-store'
10
+ import { runSideEffectGlobal } from '../../../../state/dispatch-ref'
7
11
  import { usePrStatusStore } from '../../../../state/pr-status-store'
12
+ import { getActiveWorkspace } from '../../../../state/project-workspaces'
8
13
  import { toast } from '../../../../state/toast-store'
9
14
  import { useBusySpinner } from '../../../hooks/use-busy-spinner'
10
15
  import { type ResolvedTuiTheme, useTheme, useTransparent } from '../../../theme'
@@ -22,18 +27,62 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
22
27
  const transparent = useTransparent()
23
28
  const bg = transparent ? undefined : t.backgroundElement
24
29
  const result = usePrStatusStore((s) => s.result)
30
+ const currentProjectId = useAppStore((s) => s.currentProjectId)
31
+ const projects = useAppStore((s) => s.projects)
25
32
  const [confirming, setConfirming] = useState(false)
26
33
  const [merging, setMerging] = useState(false)
27
34
  const spinner = useBusySpinner(merging)
28
35
 
29
36
  const pr = result?.kind === 'ok' ? result.pr : null
37
+ const status = result?.kind === 'ok' ? prActionState(result.pr, result.checks) : null
30
38
  const prUrl = pr?.url ?? ''
31
39
 
40
+ // The PR is polled against the active workspace's path, so its branch is this
41
+ // workspace's branch — which is what makes offering the removal here honest.
42
+ const project = projects.find((entry) => entry.id === currentProjectId)
43
+ const workspace = getActiveWorkspace(project)
44
+ const projectId = project?.id ?? null
45
+ const removableWorkspaceId =
46
+ workspace !== undefined && workspace.source !== 'primary' ? workspace.id : null
47
+
48
+ const base = pr?.base ?? ''
49
+ const cleanupKind =
50
+ pr === null ? null : prCleanupKind(status?.action ?? null, pr, removableWorkspaceId !== null)
51
+
32
52
  const openPr = useCallback(() => openUrl(prUrl), [prUrl])
33
53
  const askConfirm = useCallback(() => setConfirming(true), [])
34
54
  const cancel = useCallback(() => setConfirming(false), [])
35
55
  const confirm = useCallback(() => {
36
56
  setConfirming(false)
57
+ if (cleanupKind === 'worktree') {
58
+ if (projectId === null || removableWorkspaceId === null) return
59
+ // closeTabs (not force) mirrors the sidebar's "Remove workspace": the
60
+ // workspace's tabs are disposed, but a dirty worktree still re-prompts for
61
+ // an explicit force-delete instead of silently discarding work.
62
+ runSideEffectGlobal({
63
+ closeTabs: true,
64
+ projectId,
65
+ type: 'delete-workspace',
66
+ workspaceId: removableWorkspaceId,
67
+ })
68
+ return
69
+ }
70
+ if (cleanupKind === 'branch') {
71
+ void (async () => {
72
+ try {
73
+ // Queued like every other mutating git op: the pollers read this same
74
+ // checkout, and a checkout mid-status is how you get a torn panel.
75
+ await enqueueGitOp(async () => checkoutBranch(projectPath, base))
76
+ } catch (error) {
77
+ toast.error(error instanceof Error ? error.message : String(error))
78
+ return
79
+ }
80
+ // The branch poller would catch up within its tick; refreshing here
81
+ // retires the row now instead of leaving a merged PR sitting on screen.
82
+ await refreshPrStatus(projectPath)
83
+ })()
84
+ return
85
+ }
37
86
  setMerging(true)
38
87
  void (async () => {
39
88
  const merged = await approveAndMergePr(projectPath)
@@ -42,23 +91,18 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
42
91
  else toast.error(merged.message)
43
92
  await refreshPrStatus(projectPath)
44
93
  })()
45
- }, [projectPath])
94
+ }, [base, cleanupKind, projectId, projectPath, removableWorkspaceId])
46
95
 
47
- // First fetch still in flight: hold the band empty so the tabs below don't
48
- // jump a row once the PR lands.
49
- if (result === null) {
50
- return (
51
- <box backgroundColor={bg} paddingLeft={1} paddingRight={1}>
52
- <text selectable={false} bg={bg} wrapMode="none">
53
- {' '}
54
- </text>
55
- </box>
56
- )
57
- }
58
- if (result.kind !== 'ok' || pr === null) return null
59
- const status = prActionState(pr, result.checks)
96
+ // Nothing known yet (or nothing to show): stay out of the layout entirely and
97
+ // appear only once a fetch reports a PR.
98
+ if (result?.kind !== 'ok' || pr === null || status === null) return null
99
+ const showAction = cleanupKind !== null || status.action === 'merge'
60
100
  let label = status.label
61
- if (confirming) label = 'Merge this PR?'
101
+ if (confirming) {
102
+ if (cleanupKind === 'worktree') label = 'Remove this worktree?'
103
+ else if (cleanupKind === 'branch') label = `Switch to ${base}?`
104
+ else label = 'Merge this PR?'
105
+ }
62
106
  if (merging) label = `${spinner} merging…`
63
107
 
64
108
  return (
@@ -91,10 +135,10 @@ export const PrStateRow = memo(function PrStateRow({ projectPath }: { projectPat
91
135
  </text>
92
136
  </box>
93
137
  ) : null}
94
- {status.action === 'merge' && !confirming && !merging ? (
138
+ {showAction && !confirming && !merging ? (
95
139
  <box flexShrink={0}>
96
140
  <text selectable={false} fg={t.primary} bg={bg} wrapMode="none" onMouseDown={askConfirm}>
97
- <strong>Merge</strong>
141
+ <strong>{cleanupKind === null ? 'Merge' : 'Clean up'}</strong>
98
142
  </text>
99
143
  </box>
100
144
  ) : null}