@cat-factory/app 0.111.3 → 0.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
1
+ <script setup lang="ts">
2
+ // Implementation-fork decision window — the dedicated surface for the read-only proposer's
3
+ // materially different implementation approaches, opened via the universal result-view host
4
+ // (`ui.openForkDecision`). It reads the live fork state straight off the run's Coder step
5
+ // (`step.forkDecision`, kept fresh by the execution stream) and lets a human pick a proposed
6
+ // fork OR enter their own free-text approach. Once chosen, the Coder re-runs with the chosen
7
+ // approach folded in. Chat is added in a later slice.
8
+ import { computed, ref, watch } from 'vue'
9
+ import { useResultView } from '~/composables/useResultView'
10
+ import { useExecutionStore } from '~/stores/execution'
11
+ import { useBoardStore } from '~/stores/board'
12
+ import { useForkDecisionStore } from '~/stores/forkDecision'
13
+ import type { ForkDecisionStepState, ForkOption } from '~/types/execution'
14
+ import { FORK_DECISION_META } from '~/utils/catalog'
15
+
16
+ const execution = useExecutionStore()
17
+ const board = useBoardStore()
18
+ const forkDecision = useForkDecisionStore()
19
+
20
+ const { t } = useI18n()
21
+
22
+ // Hybrid: state rides the coder step (like follow-ups), but warm it from the GET on open too.
23
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('fork-decision', {
24
+ onOpen: (id) => void forkDecision.load(id),
25
+ })
26
+
27
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
28
+ const instance = computed(() =>
29
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
30
+ )
31
+ const step = computed(() => {
32
+ if (instance.value === null || stepIndex.value === null) return null
33
+ return instance.value.steps[stepIndex.value] ?? null
34
+ })
35
+ const state = computed<ForkDecisionStepState | null>(() => step.value?.forkDecision ?? null)
36
+ const status = computed(() => state.value?.status ?? null)
37
+ const forks = computed<ForkOption[]>(() => state.value?.forks ?? [])
38
+ const awaiting = computed(() => status.value === 'awaiting_choice')
39
+
40
+ // The human's selection: a proposed fork id, or the sentinel 'custom' for the free-text path.
41
+ const selected = ref<string | null>(null)
42
+ const customText = ref('')
43
+ const note = ref('')
44
+
45
+ // Default the selection to the recommended fork whenever the fork set changes.
46
+ watch(
47
+ forks,
48
+ (list) => {
49
+ if (
50
+ selected.value &&
51
+ (selected.value === 'custom' || list.some((f) => f.id === selected.value))
52
+ )
53
+ return
54
+ selected.value = list.find((f) => f.recommended)?.id ?? list[0]?.id ?? null
55
+ },
56
+ { immediate: true },
57
+ )
58
+
59
+ const canChoose = computed(() => {
60
+ if (!awaiting.value || forkDecision.choosing) return false
61
+ if (selected.value === 'custom') return customText.value.trim().length > 0
62
+ return selected.value != null
63
+ })
64
+
65
+ async function onChoose() {
66
+ const id = instanceId.value
67
+ if (!id || !canChoose.value) return
68
+ const noteText = note.value.trim() || undefined
69
+ const choice =
70
+ selected.value === 'custom'
71
+ ? { custom: customText.value.trim(), note: noteText }
72
+ : { forkId: selected.value!, note: noteText }
73
+ await forkDecision.choose(id, choice).catch(() => {})
74
+ }
75
+ </script>
76
+
77
+ <template>
78
+ <Teleport to="body">
79
+ <div
80
+ v-if="open"
81
+ data-testid="fork-decision-window"
82
+ class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
83
+ @click.self="close"
84
+ >
85
+ <div
86
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
87
+ role="dialog"
88
+ aria-modal="true"
89
+ >
90
+ <!-- Header -->
91
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
92
+ <span
93
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-violet-500/15 text-violet-300"
94
+ >
95
+ <UIcon :name="FORK_DECISION_META.icon" class="h-4 w-4" />
96
+ </span>
97
+ <div class="min-w-0 flex-1">
98
+ <h2 class="truncate text-sm font-semibold text-slate-100">
99
+ {{
100
+ block
101
+ ? t('forkDecision.titleWithBlock', { title: block.title })
102
+ : t('forkDecision.title')
103
+ }}
104
+ </h2>
105
+ <p class="truncate text-[11px] text-slate-400">{{ t('forkDecision.subtitle') }}</p>
106
+ </div>
107
+ <button
108
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
109
+ @click="close"
110
+ >
111
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
112
+ </button>
113
+ </header>
114
+
115
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
116
+ <!-- Proposing: the read-only proposer is still working. -->
117
+ <div
118
+ v-if="status === 'proposing'"
119
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
120
+ >
121
+ <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
122
+ <p class="text-sm">{{ t('forkDecision.proposing.title') }}</p>
123
+ <p class="max-w-sm text-[11px] text-slate-500">
124
+ {{ t('forkDecision.proposing.hint') }}
125
+ </p>
126
+ </div>
127
+
128
+ <!-- A single path (no materially different alternatives): a read-only record. -->
129
+ <div
130
+ v-else-if="status === 'single_path'"
131
+ class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-slate-300"
132
+ >
133
+ <p class="text-[13px] font-medium text-slate-100">
134
+ {{ t('forkDecision.singlePath.title') }}
135
+ </p>
136
+ <p v-if="state?.singlePathReason" class="mt-1 text-[12px]">
137
+ {{ state.singlePathReason }}
138
+ </p>
139
+ </div>
140
+
141
+ <!-- Chosen: a read-only record of what was decided. -->
142
+ <div
143
+ v-else-if="status === 'chosen'"
144
+ class="rounded-xl border border-violet-500/40 bg-slate-900/60 px-4 py-3 text-slate-300"
145
+ >
146
+ <p class="text-[13px] font-medium text-violet-200">
147
+ {{ t('forkDecision.chosen.title') }}
148
+ </p>
149
+ <p v-if="state?.chosen?.custom" class="mt-1 whitespace-pre-wrap text-[12px]">
150
+ {{ state.chosen.custom }}
151
+ </p>
152
+ <p v-else-if="state?.chosen?.forkId" class="mt-1 text-[12px]">
153
+ {{ forks.find((f) => f.id === state?.chosen?.forkId)?.title }}
154
+ </p>
155
+ <p v-if="state?.chosen?.note" class="mt-1 text-[11px] text-slate-400">
156
+ {{ t('forkDecision.chosen.note', { note: state.chosen.note }) }}
157
+ </p>
158
+ </div>
159
+
160
+ <!-- Awaiting the human's choice. -->
161
+ <div v-else-if="awaiting" class="space-y-3">
162
+ <p
163
+ v-if="forkDecision.error"
164
+ class="rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
165
+ >
166
+ {{ forkDecision.error }}
167
+ </p>
168
+
169
+ <p
170
+ v-if="state?.seamSummary"
171
+ class="rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
172
+ >
173
+ <span class="text-slate-500">{{ t('forkDecision.seam') }}</span>
174
+ {{ state.seamSummary }}
175
+ </p>
176
+
177
+ <!-- Proposed fork cards -->
178
+ <article
179
+ v-for="fork in forks"
180
+ :key="fork.id"
181
+ data-testid="fork-option-card"
182
+ class="cursor-pointer rounded-xl border px-4 py-3 transition"
183
+ :class="
184
+ selected === fork.id
185
+ ? 'border-violet-500/70 bg-violet-500/5'
186
+ : 'border-slate-800 bg-slate-900/60 hover:border-slate-700'
187
+ "
188
+ @click="selected = fork.id"
189
+ >
190
+ <div class="flex items-start gap-2">
191
+ <input
192
+ type="radio"
193
+ class="mt-1 accent-violet-500"
194
+ :checked="selected === fork.id"
195
+ @change="selected = fork.id"
196
+ />
197
+ <div class="min-w-0 flex-1">
198
+ <div class="flex items-center gap-2">
199
+ <h3 class="min-w-0 flex-1 text-[13px] font-medium text-slate-100">
200
+ {{ fork.title }}
201
+ </h3>
202
+ <UBadge v-if="fork.recommended" color="primary" variant="subtle" size="sm">
203
+ {{ t('forkDecision.recommended') }}
204
+ </UBadge>
205
+ </div>
206
+ <p v-if="fork.summary" class="mt-0.5 text-[12px] text-slate-400">
207
+ {{ fork.summary }}
208
+ </p>
209
+ <p class="mt-1.5 whitespace-pre-wrap text-[12px] text-slate-300">
210
+ {{ fork.approach }}
211
+ </p>
212
+ <ul v-if="fork.tradeoffs.length" class="mt-1.5 space-y-0.5">
213
+ <li
214
+ v-for="(tr, i) in fork.tradeoffs"
215
+ :key="i"
216
+ class="flex gap-1.5 text-[11px] text-slate-400"
217
+ >
218
+ <span class="text-slate-600">•</span>{{ tr }}
219
+ </li>
220
+ </ul>
221
+ <p v-if="fork.riskNotes" class="mt-1.5 text-[11px] text-amber-300/90">
222
+ <span class="text-amber-500/70">{{ t('forkDecision.riskNotes') }}</span>
223
+ {{ fork.riskNotes }}
224
+ </p>
225
+ </div>
226
+ </div>
227
+ </article>
228
+
229
+ <!-- Custom approach -->
230
+ <article
231
+ class="rounded-xl border px-4 py-3 transition"
232
+ :class="
233
+ selected === 'custom'
234
+ ? 'border-violet-500/70 bg-violet-500/5'
235
+ : 'border-slate-800 bg-slate-900/60'
236
+ "
237
+ >
238
+ <label class="flex cursor-pointer items-center gap-2" @click="selected = 'custom'">
239
+ <input type="radio" class="accent-violet-500" :checked="selected === 'custom'" />
240
+ <span class="text-[13px] font-medium text-slate-100">{{
241
+ t('forkDecision.custom.title')
242
+ }}</span>
243
+ </label>
244
+ <textarea
245
+ v-model="customText"
246
+ data-testid="fork-custom-input"
247
+ rows="3"
248
+ :placeholder="t('forkDecision.custom.placeholder')"
249
+ class="mt-2 w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-500/60"
250
+ @focus="selected = 'custom'"
251
+ />
252
+ </article>
253
+
254
+ <!-- Optional steering note -->
255
+ <div>
256
+ <label class="mb-1 block text-[11px] text-slate-400">{{
257
+ t('forkDecision.noteLabel')
258
+ }}</label>
259
+ <input
260
+ v-model="note"
261
+ type="text"
262
+ :placeholder="t('forkDecision.notePlaceholder')"
263
+ class="w-full rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-violet-500 focus:outline-none"
264
+ />
265
+ </div>
266
+ </div>
267
+
268
+ <!-- Skipped / no state: nothing to decide. -->
269
+ <div
270
+ v-else
271
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
272
+ >
273
+ <UIcon :name="FORK_DECISION_META.icon" class="h-8 w-8 opacity-40" />
274
+ <p class="text-sm">{{ t('forkDecision.empty.title') }}</p>
275
+ </div>
276
+ </div>
277
+
278
+ <footer
279
+ v-if="awaiting"
280
+ class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
281
+ >
282
+ <UButton color="neutral" variant="ghost" size="sm" @click="close">
283
+ {{ t('common.cancel') }}
284
+ </UButton>
285
+ <UButton
286
+ data-testid="fork-option-choose"
287
+ color="primary"
288
+ size="sm"
289
+ icon="i-lucide-check"
290
+ :loading="forkDecision.choosing"
291
+ :disabled="!canChoose"
292
+ @click="onChoose"
293
+ >
294
+ {{ t('forkDecision.choose') }}
295
+ </UButton>
296
+ </footer>
297
+ </div>
298
+ </div>
299
+ </Teleport>
300
+ </template>
@@ -60,6 +60,10 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
60
60
  // Clicking the title opens the Follow-up companion window for the run (see `reveal`); "act"
61
61
  // just marks it read (items are decided in that window — file / send back / answer — not here).
62
62
  followup_pending: { icon: 'i-lucide-compass', color: 'warning' },
63
+ // The fork-decision phase surfaced materially different implementation approaches. Clicking
64
+ // the title opens the fork-decision window (see `reveal`); "act" just marks it read (the
65
+ // choice is made in that window — pick a fork / enter a custom approach — not here).
66
+ fork_decision_pending: { icon: 'i-lucide-git-fork', color: 'warning' },
63
67
  // The initiative loop needs attention (a blocked task, or completion). Clicking the title
64
68
  // opens the initiative tracker window; "act" just marks it read.
65
69
  initiative: { icon: 'i-lucide-milestone', color: 'primary' },
@@ -81,6 +85,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
81
85
  visual_confirmation_ready: 'layout.notifications.action.visual_confirmation_ready',
82
86
  human_review: 'layout.notifications.action.human_review',
83
87
  followup_pending: 'layout.notifications.action.followup_pending',
88
+ fork_decision_pending: 'layout.notifications.action.fork_decision_pending',
84
89
  initiative: 'layout.notifications.action.initiative',
85
90
  }
86
91
 
@@ -151,6 +156,7 @@ function reveal(n: Notification) {
151
156
  else if (n.type === 'visual_confirmation_ready') revealVisualConfirm(n)
152
157
  else if (n.type === 'human_review') revealHumanReview(n)
153
158
  else if (n.type === 'followup_pending') revealFollowUps(n)
159
+ else if (n.type === 'fork_decision_pending') revealForkDecision(n)
154
160
  else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
155
161
  else ui.select(n.blockId)
156
162
  }
@@ -176,6 +182,15 @@ function revealFollowUps(n: Notification) {
176
182
  else if (n.blockId) ui.select(n.blockId)
177
183
  }
178
184
 
185
+ /**
186
+ * Open the implementation-fork decision window for a run parked awaiting a fork choice.
187
+ * Falls back to focusing the block when the run isn't loaded.
188
+ */
189
+ function revealForkDecision(n: Notification) {
190
+ if (n.executionId && execution.getInstance(n.executionId)) ui.openForkDecision(n.executionId)
191
+ else if (n.blockId) ui.select(n.blockId)
192
+ }
193
+
179
194
  /**
180
195
  * Open the human-testing window for a parked `human-test` gate: find the run's parked
181
196
  * human-test step and open it through the universal step dispatch (its archetype declares
@@ -22,6 +22,7 @@ import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindo
22
22
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
23
23
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
24
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
+ import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
25
26
  import MergerResultView from '~/components/panels/MergerResultView.vue'
26
27
  import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
27
28
  import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
@@ -52,6 +53,10 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
52
53
  // The future-looking Follow-up companion: the Coder's surfaced loose ends / questions.
53
54
  // Opened directly via `ui.openFollowUps` (the blinking chip + the `followup_pending` card).
54
55
  'follow-ups': FollowUpWindow,
56
+ // The implementation-fork decision: the proposer's materially different approaches + the
57
+ // human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
58
+ // chip, the inspector button, and the `fork_decision_pending` card).
59
+ 'fork-decision': ForkDecisionWindow,
55
60
  // The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
56
61
  // or awaiting-review decision (and why), instead of the agent's raw JSON.
57
62
  merger: MergerResultView,
@@ -0,0 +1,247 @@
1
+ <script setup lang="ts">
2
+ // Pre-existing branches of a task's PRIMARY target repo handed to the run as input, in two
3
+ // deliberately-disjoint modes (see `backend/docs/adr/0021-apriori-branches.md`):
4
+ //
5
+ // - `reference` — read-only context (a spike / prototype / prior-art branch). The consuming
6
+ // agents may read it (log/diff/open files) but never commit to or push it.
7
+ // - `working` — the branch the run keeps building inside: it starts from and continues
8
+ // committing into this branch instead of minting `cat-factory/<blockId>` off the default,
9
+ // and the PR / CI-gate / merger all ride it.
10
+ //
11
+ // The cross-entry invariants the backend enforces at the write boundary are mirrored here so a
12
+ // forbidden combination is prevented in the UI rather than surfaced as a rejected write: at most
13
+ // ONE working entry, no duplicate names, the working entry frozen once a PR exists (its head is
14
+ // already pinned everywhere), and no working entry on a multi-repo task (v1 — peer legs would
15
+ // mint the user's branch name across every involved repo).
16
+ import { aprioriWorkingBranch } from '@cat-factory/contracts'
17
+ import type { AprioriBranch, Block } from '~/types/domain'
18
+
19
+ const props = defineProps<{ block: Block }>()
20
+
21
+ const { t } = useI18n()
22
+ const github = useGitHubStore()
23
+ const board = useBoardStore()
24
+
25
+ // The primary target repo is the one bound to the task's owning service frame — the sole
26
+ // repo↔frame linkage. Branch options come from the existing per-repo branches projection.
27
+ const frame = computed(() => board.serviceOf(props.block))
28
+ const repo = computed(() => (frame.value ? github.repoForBlock(frame.value.id) : undefined))
29
+ const repoBranches = computed(() => {
30
+ const id = repo.value?.githubId
31
+ return id != null ? (github.branches[id] ?? []) : []
32
+ })
33
+
34
+ // Load (and cache) the target repo's branches once it's resolved. Best-effort — a fetch failure
35
+ // just leaves the picker empty (the same repo the run clones, so a real failure is rare).
36
+ watch(
37
+ () => repo.value?.githubId,
38
+ (id) => {
39
+ if (id != null) void github.loadBranches(id).catch(() => {})
40
+ },
41
+ { immediate: true },
42
+ )
43
+
44
+ // Write-boundary mirrors:
45
+ // - a PR pins the run's branch, so the working entry is FROZEN (references stay editable);
46
+ // - a multi-repo task (any involved service) BLOCKS working mode entirely.
47
+ const hasPullRequest = computed(() => !!props.block.pullRequest)
48
+ const isMultiRepo = computed(() => (props.block.involvedServiceIds ?? []).length > 0)
49
+
50
+ // A working entry set while single-repo becomes invalid the moment the task gains a second
51
+ // involved service (the backend rejects a working entry on a multi-repo task). Rather than let
52
+ // that stale entry ride along and fail the NEXT write wholesale, demote any working entry to
53
+ // `reference` on a multi-repo task — applied both to what we render and to what we persist, so
54
+ // the invariant is mirrored (not surfaced as a rejected write) and self-heals on the next save.
55
+ function normalize(entries: AprioriBranch[]): AprioriBranch[] {
56
+ if (!isMultiRepo.value) return entries
57
+ return entries.map((b) => (b.mode === 'working' ? { ...b, mode: 'reference' } : b))
58
+ }
59
+
60
+ const attached = computed<AprioriBranch[]>(() => normalize(props.block.aprioriBranches ?? []))
61
+ const attachedNames = computed(() => new Set(attached.value.map((b) => b.name)))
62
+ const workingName = computed(() => aprioriWorkingBranch(attached.value))
63
+
64
+ function isProtected(name: string): boolean {
65
+ return repoBranches.value.find((b) => b.name === name)?.protected === true
66
+ }
67
+ // Building the run inside the repo's base branch has nothing to diff and no PR to open, so it's
68
+ // rejected at dispatch — surface it here as a non-selectable working target.
69
+ function isBaseBranch(name: string): boolean {
70
+ return repo.value?.defaultBranch != null && name === repo.value.defaultBranch
71
+ }
72
+
73
+ function save(next: AprioriBranch[]) {
74
+ board.updateBlock(props.block.id, { aprioriBranches: normalize(next) })
75
+ }
76
+
77
+ // ---- add / remove -----------------------------------------------------------
78
+ // The picker adds a branch as `reference` (the safe default — promoting to working is an
79
+ // explicit second action, guarded below).
80
+ const pickedName = ref<string | undefined>(undefined)
81
+ watch(pickedName, (name) => {
82
+ if (name === undefined) return
83
+ pickedName.value = undefined
84
+ if (attachedNames.value.has(name)) return
85
+ save([...attached.value, { name, mode: 'reference' }])
86
+ })
87
+
88
+ const branchItems = computed(() =>
89
+ repoBranches.value.map((b) => ({
90
+ label: b.name,
91
+ value: b.name,
92
+ disabled: attachedNames.value.has(b.name),
93
+ })),
94
+ )
95
+
96
+ function remove(name: string) {
97
+ save(attached.value.filter((b) => b.name !== name))
98
+ }
99
+
100
+ // ---- mode toggle ------------------------------------------------------------
101
+ // Promoting an entry to `working` demotes any existing working entry to `reference` in the same
102
+ // write, so the single-working invariant holds without an intermediate rejected state.
103
+ function setMode(name: string, mode: AprioriBranch['mode']) {
104
+ save(
105
+ attached.value.map((b) => {
106
+ if (b.name === name) return { ...b, mode }
107
+ if (mode === 'working' && b.mode === 'working') return { ...b, mode: 'reference' }
108
+ return b
109
+ }),
110
+ )
111
+ }
112
+
113
+ // Whether a reference entry may be promoted to working: blocked outright on a multi-repo task,
114
+ // on the base branch, and once a PR has frozen the working slot.
115
+ function canPromote(name: string): boolean {
116
+ return !isMultiRepo.value && !hasPullRequest.value && !isBaseBranch(name)
117
+ }
118
+ // The working entry is frozen once the PR exists (changing/dropping it would silently diverge).
119
+ const workingFrozen = computed(() => hasPullRequest.value && workingName.value !== undefined)
120
+
121
+ function modeMenu(entry: AprioriBranch) {
122
+ const items: Array<{ label: string; icon: string; onSelect: () => void }> = []
123
+ if (entry.mode === 'working') {
124
+ if (!workingFrozen.value) {
125
+ items.push({
126
+ label: t('inspector.aprioriBranches.mode.reference'),
127
+ icon: 'i-lucide-book-open-text',
128
+ onSelect: () => setMode(entry.name, 'reference'),
129
+ })
130
+ }
131
+ } else if (canPromote(entry.name)) {
132
+ items.push({
133
+ label: t('inspector.aprioriBranches.mode.working'),
134
+ icon: 'i-lucide-hammer',
135
+ onSelect: () => setMode(entry.name, 'working'),
136
+ })
137
+ }
138
+ return [items]
139
+ }
140
+ // The mode dropdown is inert when there's no alternative mode to switch to.
141
+ function modeToggleDisabled(entry: AprioriBranch): boolean {
142
+ return modeMenu(entry)[0]!.length === 0
143
+ }
144
+ // The working entry can't be removed while frozen by a PR; reference entries are always removable.
145
+ function removeDisabled(entry: AprioriBranch): boolean {
146
+ return entry.mode === 'working' && workingFrozen.value
147
+ }
148
+ </script>
149
+
150
+ <template>
151
+ <div v-if="repo" data-testid="apriori-branches">
152
+ <div class="mb-1 flex items-center justify-between">
153
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
154
+ {{ t('inspector.aprioriBranches.title') }}
155
+ </span>
156
+ </div>
157
+
158
+ <!-- Attached branches: one row each — name, mode badge + toggle, remove. -->
159
+ <div v-if="attached.length" class="mb-1.5 space-y-1">
160
+ <div
161
+ v-for="entry in attached"
162
+ :key="entry.name"
163
+ class="flex items-center gap-1.5"
164
+ data-testid="apriori-branch-row"
165
+ >
166
+ <UBadge
167
+ size="sm"
168
+ variant="soft"
169
+ :color="entry.mode === 'working' ? 'primary' : 'neutral'"
170
+ class="min-w-0"
171
+ :data-mode="entry.mode"
172
+ data-testid="apriori-branch-chip"
173
+ >
174
+ <UIcon
175
+ :name="entry.mode === 'working' ? 'i-lucide-hammer' : 'i-lucide-book-open-text'"
176
+ class="me-1 h-3 w-3 shrink-0"
177
+ />
178
+ <span class="truncate">{{ entry.name }}</span>
179
+ </UBadge>
180
+
181
+ <UDropdownMenu :items="modeMenu(entry)">
182
+ <UButton
183
+ size="xs"
184
+ variant="ghost"
185
+ color="neutral"
186
+ trailing-icon="i-lucide-chevron-down"
187
+ :disabled="modeToggleDisabled(entry)"
188
+ data-testid="apriori-branch-mode"
189
+ >
190
+ {{
191
+ entry.mode === 'working'
192
+ ? t('inspector.aprioriBranches.mode.working')
193
+ : t('inspector.aprioriBranches.mode.reference')
194
+ }}
195
+ </UButton>
196
+ </UDropdownMenu>
197
+
198
+ <UButton
199
+ color="neutral"
200
+ variant="link"
201
+ size="xs"
202
+ icon="i-lucide-x"
203
+ class="ms-auto"
204
+ :disabled="removeDisabled(entry)"
205
+ :aria-label="t('inspector.aprioriBranches.remove', { branch: entry.name })"
206
+ data-testid="apriori-branch-remove"
207
+ @click="remove(entry.name)"
208
+ />
209
+ </div>
210
+ </div>
211
+
212
+ <!-- The picker: only usable once the workspace's GitHub App is connected. -->
213
+ <UInputMenu
214
+ v-if="github.connected"
215
+ v-model="pickedName"
216
+ :items="branchItems"
217
+ value-key="value"
218
+ icon="i-lucide-git-branch"
219
+ :placeholder="t('inspector.aprioriBranches.searchPlaceholder')"
220
+ class="w-full"
221
+ data-testid="apriori-branch-search"
222
+ />
223
+ <div v-else class="text-[11px] text-slate-500">
224
+ {{ t('inspector.aprioriBranches.connectFirst') }}
225
+ </div>
226
+
227
+ <!-- A protected branch pushed to by the run is likely to be rejected — warn, don't block. -->
228
+ <div
229
+ v-if="workingName && isProtected(workingName)"
230
+ class="mt-1.5 flex items-start gap-1.5 rounded-md border border-amber-500/40 bg-amber-950/40 p-2 text-[11px] text-amber-200/90"
231
+ data-testid="apriori-branch-protected-warning"
232
+ >
233
+ <UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400" />
234
+ <span>{{ t('inspector.aprioriBranches.protectedWarning', { branch: workingName }) }}</span>
235
+ </div>
236
+
237
+ <div class="mt-1 text-[11px] text-slate-500">
238
+ {{ t('inspector.aprioriBranches.hint') }}
239
+ <template v-if="isMultiRepo">
240
+ {{ t('inspector.aprioriBranches.multiRepoHint') }}
241
+ </template>
242
+ <template v-else-if="workingFrozen">
243
+ {{ t('inspector.aprioriBranches.frozenHint') }}
244
+ </template>
245
+ </div>
246
+ </div>
247
+ </template>
@@ -136,6 +136,11 @@ function openStep(i: number) {
136
136
  if (instance.value) ui.openStepDetail(instance.value.id, i)
137
137
  }
138
138
 
139
+ // Open the implementation-fork decision window for a coder step parked awaiting a choice.
140
+ function openForkFor(i: number) {
141
+ if (instance.value) ui.openForkDecision(instance.value.id, i)
142
+ }
143
+
139
144
  // Stop the run WITHOUT deleting it: halts the container + driver and records a
140
145
  // `cancelled` failure, leaving the run readable + retryable (the block goes
141
146
  // `blocked`). The destructive reset (delete the run, return the task to `planned`)
@@ -335,6 +340,22 @@ async function mergePr() {
335
340
  >
336
341
  {{ t('inspector.execution.decide') }}
337
342
  </UButton>
343
+ <!-- A coder step parked on the implementation-fork decision: pick an approach
344
+ (or enter a custom one) in the dedicated window, not a plain approval. -->
345
+ <UButton
346
+ v-else-if="
347
+ s.approval &&
348
+ s.approval.status === 'pending' &&
349
+ s.forkDecision?.status === 'awaiting_choice'
350
+ "
351
+ color="primary"
352
+ variant="soft"
353
+ size="xs"
354
+ icon="i-lucide-git-fork"
355
+ @click="openForkFor(i)"
356
+ >
357
+ {{ t('inspector.execution.chooseApproach') }}
358
+ </UButton>
338
359
  <UButton
339
360
  v-else-if="s.approval && s.approval.status === 'pending'"
340
361
  color="warning"
@@ -6,6 +6,7 @@ import type { WritebackOverride } from '~/types/tracker'
6
6
  import { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
7
7
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
8
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
9
+ import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
9
10
 
10
11
  const props = defineProps<{ block: Block }>()
11
12
 
@@ -480,6 +481,10 @@ const technicalLabel = computed(() => {
480
481
  </div>
481
482
  </div>
482
483
 
484
+ <!-- apriori branches: pre-existing branches of the target repo handed to the run as input
485
+ (a read-only reference, or the working branch the run builds inside) -->
486
+ <TaskAprioriBranches :block="block" />
487
+
483
488
  <!-- reference repositories: read-only repos the doc-writer reads while drafting (doc tasks) -->
484
489
  <DocReferenceRepos v-if="block.taskType === 'document'" :block="block" />
485
490