@cat-factory/app 0.182.0 → 0.183.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.
package/README.md CHANGED
@@ -177,9 +177,13 @@ Two placements are load-bearing enough to state, because putting a new one in th
177
177
  wrong place is invisible until a user cannot find it:
178
178
 
179
179
  - **The sidebar section is a claim about what the destination IS.** `models` is the
180
- engines, `integrations` the optional systems, `infrastructure` where agent
181
- containers and test environments run, `configuration` workspace/account settings.
182
- `nav-contributions.spec.ts` pins the section order and each section's membership.
180
+ model layer — the engines, the per-agent model choice, and the surfaces that
181
+ evaluate a prompt+agent+model combination (Sandbox, Kaizen); `integrations` the
182
+ optional EXTERNAL systems; `infrastructure` where agent containers and test
183
+ environments run; `configuration` workspace/account settings. A surface that
184
+ connects to nothing does not belong in `integrations` however configuration-shaped
185
+ it feels. `nav-contributions.spec.ts` pins the section order and each section's
186
+ membership.
183
187
  - **A flow that edits ONE entity's config is a section of the window that owns that
184
188
  config, not a sibling nav entry.** The guided Docker Compose environment setup
185
189
  (`ComposeEnvironmentSetupSection.vue` → `EnvironmentSetupWizard.vue`) lives inside
@@ -188,6 +192,15 @@ wrong place is invisible until a user cannot find it:
188
192
  It also carries a full "how it works / when you need this / when you can skip it"
189
193
  explanation there rather than a one-line hint, since the decision to run it has to
190
194
  be made before opening a five-minute wizard.
195
+ - **"It talks to an external service" is not what puts a surface in `integrations`.**
196
+ Private package registries connect to npmjs.com and GitHub Packages and still belong
197
+ in Infrastructure, because the question they answer is _what may a container install
198
+ from_ — a property of where agents RUN, which is what the Infrastructure window is
199
+ for. `integrations` is for a system the WORKSPACE links in and would still be a
200
+ coherent product without. Ask which question the destination answers, not whether a
201
+ credential leaves the building. A surface moved between sections must also move its
202
+ entry point: leaving a hub row behind as a shortcut splits the answer across two
203
+ places, so the row goes and the window's tab becomes the single route in.
191
204
 
192
205
  ## Develop & test
193
206
 
@@ -83,12 +83,22 @@ const selected = computed(() => ui.selectedBlockId === props.id)
83
83
  // kept (gated off) so the prior behaviour is one edit away if we want chips back.
84
84
  const showExpanded = computed(() => true)
85
85
 
86
- // Surface a pending decision from this frame OR any of its tasks (O(tasks) map
86
+ // Every child whose parked run the frame badge speaks for: its tasks AND its initiative
87
+ // blocks. An initiative is a frame child like a module and runs an ordinary pipeline (its
88
+ // planner parks on a real approval gate), so leaving it out made a whole class of parked run
89
+ // invisible at frame level — the badge read "nothing needs you" while a plan sat waiting.
90
+ const attentionIds = computed(() => {
91
+ const ids = new Set(taskIds.value)
92
+ for (const i of initiativeBlocks.value) ids.add(i.id)
93
+ return ids
94
+ })
95
+
96
+ // Surface a pending decision from this frame OR any of its children (O(children) map
87
97
  // lookups, not a scan of every open decision per frame).
88
98
  const blockDecisions = computed(() => {
89
99
  const byBlock = execution.decisionsByBlock
90
100
  const out = [...(byBlock.get(props.id) ?? [])]
91
- for (const id of taskIds.value) {
101
+ for (const id of attentionIds.value) {
92
102
  const list = byBlock.get(id)
93
103
  if (list) out.push(...list)
94
104
  }
@@ -100,14 +110,14 @@ function openFirstDecision() {
100
110
  if (d) ui.openDecision(d.instanceId, d.decision.id)
101
111
  }
102
112
 
103
- // Surface a pending approval gate from this frame OR any of its tasks — but NOT an
113
+ // Surface a pending approval gate from this frame OR any of its children — but NOT an
104
114
  // iterative reviewer gate (requirements-review / clarity-review) that's mid-cycle
105
115
  // (incorporating / re-reviewing in the driver), which is background work needing no human,
106
116
  // so it stays off the frame's "Approval" badge.
107
117
  const blockApprovals = computed(() => {
108
118
  const byBlock = execution.approvalsByBlock
109
119
  const candidates = [...(byBlock.get(props.id) ?? [])]
110
- for (const id of taskIds.value) {
120
+ for (const id of attentionIds.value) {
111
121
  const list = byBlock.get(id)
112
122
  if (list) candidates.push(...list)
113
123
  }
@@ -5,12 +5,18 @@
5
5
  // initiative's equivalent "Run planning" (and, while parked mid-interview, "Answer
6
6
  // planning questions") lives right here on the board — the same actions the
7
7
  // inspector offers — so starting an initiative isn't hidden behind selecting it.
8
+ // It likewise mirrors a task card's `attention` affordance: a planning run parked
9
+ // on the plan-approval gate (or on an agent-raised decision) offers the button that
10
+ // opens the window resolving it, instead of leaving the card on a spinning "Run
11
+ // planning" whose only route in was the inspector's execution panel.
8
12
  // The tracker button opens the dedicated window directly. Draggable within its
9
13
  // frame like a task card.
10
14
  import type { InitiativeStatus } from '~/types/domain'
11
15
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
16
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
13
17
  import {
18
+ INITIATIVE_ATTENTION_ICONS,
19
+ INITIATIVE_ATTENTION_LABEL_KEYS,
14
20
  INITIATIVE_STATUS_CHIPS,
15
21
  INITIATIVE_STATUS_LABEL_KEYS,
16
22
  initiativeProgress,
@@ -40,6 +46,7 @@ const {
40
46
  running,
41
47
  awaitingAnswers,
42
48
  interviewing,
49
+ attention,
43
50
  starting,
44
51
  runPlanning,
45
52
  openPlanning,
@@ -79,7 +86,10 @@ function onHandle(e: PointerEvent) {
79
86
  data-testid="initiative-card"
80
87
  :data-status="status"
81
88
  class="cursor-pointer rounded-b-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
82
- :class="[selected ? 'ring-2 ring-indigo-400/60' : '', awaitingAnswers ? 'board-pulse' : '']"
89
+ :class="[
90
+ selected ? 'ring-2 ring-indigo-400/60' : '',
91
+ awaitingAnswers || attention ? 'board-pulse' : '',
92
+ ]"
83
93
  @click.stop="select"
84
94
  >
85
95
  <div class="flex items-start justify-between gap-2">
@@ -106,8 +116,23 @@ function onHandle(e: PointerEvent) {
106
116
  </div>
107
117
  </div>
108
118
  <div class="nodrag mt-2 flex flex-wrap items-center gap-1">
119
+ <!-- Parked for a human: the drafted plan awaits approval, or an agent raised a
120
+ decision. Opens the window that can resolve the park (never the generic panel,
121
+ which the server refuses for a park a dedicated window owns). -->
109
122
  <UButton
110
- v-if="awaitingAnswers"
123
+ v-if="attention"
124
+ data-testid="initiative-card-review"
125
+ :data-attention="attention.kind"
126
+ size="xs"
127
+ variant="solid"
128
+ color="warning"
129
+ :icon="INITIATIVE_ATTENTION_ICONS[attention.kind]"
130
+ @click.stop="attention.open()"
131
+ >
132
+ {{ t(INITIATIVE_ATTENTION_LABEL_KEYS[attention.kind]) }}
133
+ </UButton>
134
+ <UButton
135
+ v-else-if="awaitingAnswers"
111
136
  data-testid="initiative-card-answer-planning"
112
137
  size="xs"
113
138
  variant="solid"
@@ -1,14 +1,20 @@
1
1
  <script setup lang="ts">
2
- // The initiative tracker window — the dedicated read-only view of an initiative's
3
- // plan/tracker entity: goal + constraints, the phases with their per-item status +
4
- // PR links, the execution policy, and the decisions / deviations / follow-ups /
5
- // caveats logs. Renders the DB entity (the source of truth) — never the in-repo
6
- // mirror, which may not exist (GitHub-unwired workspaces). Opened via the universal
7
- // result-view host: from the board card / inspector (`ui.openInitiativeTracker`) or
8
- // as the planner step's result view. Live `initiative` stream events patch the
9
- // store, so an open window follows the plan as it is ingested and later executed.
10
- import { computed, reactive, ref } from 'vue'
2
+ // The initiative tracker window — the dedicated view of an initiative's plan/tracker
3
+ // entity: goal + constraints, the phases with their per-item status + PR links, the
4
+ // execution policy, and the decisions / deviations / follow-ups / caveats logs.
5
+ // Renders the DB entity (the source of truth) — never the in-repo mirror, which may
6
+ // not exist (GitHub-unwired workspaces). Opened via the universal result-view host:
7
+ // from the board card / inspector (`ui.openInitiativeTracker`) or as the planner
8
+ // step's result view. Live `initiative` stream events patch the store, so an open
9
+ // window follows the plan as it is ingested and later executed.
10
+ //
11
+ // It also OWNS the planner's plan-approval gate: this window is where the planner
12
+ // step's park routes (its archetype declares this result view), so the approve /
13
+ // request-changes rail has to live here or the gate has no resolving surface at all —
14
+ // which is exactly how an approved-only-over-REST plan gate shipped.
15
+ import { computed, reactive, ref, watch } from 'vue'
11
16
  import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
17
+ import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
12
18
  import {
13
19
  INITIATIVE_FOLLOWUP_STATUS_CHIPS,
14
20
  INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
@@ -23,6 +29,8 @@ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
23
29
 
24
30
  const board = useBoardStore()
25
31
  const initiatives = useInitiativesStore()
32
+ const execution = useExecutionStore()
33
+ const access = useWorkspaceAccess()
26
34
  const { t } = useI18n()
27
35
  const toast = useToast()
28
36
 
@@ -92,6 +100,58 @@ async function checkpointControl(action: 'resume' | 'cancel') {
92
100
  }
93
101
  }
94
102
 
103
+ // ---- Plan review: the planner step's human gate, resolved right here -----------------------
104
+ // Derived from the BLOCK (via the shared planning composable), not from this window's own
105
+ // `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
106
+ // human parked on the gate actually uses. So the rail appears on every route into the window.
107
+ const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
108
+
109
+ /** Draft feedback for "request changes" (the planner re-runs with it), and the rail's in-flight
110
+ * state. Reset when a different initiative opens so a draft can't follow the window. */
111
+ const planFeedback = ref('')
112
+ const requestingChanges = ref(false)
113
+ const resolvingPlan = ref(false)
114
+ watch(blockId, () => {
115
+ planFeedback.value = ''
116
+ requestingChanges.value = false
117
+ })
118
+
119
+ const canRequestChanges = computed(() => planFeedback.value.trim().length > 0)
120
+
121
+ /**
122
+ * Accept the drafted plan: the run advances to the committer, which persists the initiative and
123
+ * arms the execution loop. The window deliberately stays OPEN — the rail disappears with the
124
+ * approval (live), and the tracker is where the plan then starts executing.
125
+ */
126
+ async function approvePlan() {
127
+ const parked = planApproval.value
128
+ if (!parked || resolvingPlan.value) return
129
+ resolvingPlan.value = true
130
+ try {
131
+ await execution.approveStep(parked.instanceId, parked.approval.id)
132
+ } finally {
133
+ resolvingPlan.value = false
134
+ }
135
+ }
136
+
137
+ /** Send the plan back to the planner with what to change; it re-plans and parks again. */
138
+ async function submitPlanChanges() {
139
+ const parked = planApproval.value
140
+ if (!parked || resolvingPlan.value || !canRequestChanges.value) return
141
+ resolvingPlan.value = true
142
+ try {
143
+ const ok = await execution.requestStepChanges(parked.instanceId, parked.approval.id, {
144
+ feedback: planFeedback.value.trim(),
145
+ })
146
+ if (ok) {
147
+ planFeedback.value = ''
148
+ requestingChanges.value = false
149
+ }
150
+ } finally {
151
+ resolvingPlan.value = false
152
+ }
153
+ }
154
+
95
155
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
96
156
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
97
157
  const axes = [
@@ -234,6 +294,86 @@ async function savePolicy() {
234
294
  </div>
235
295
 
236
296
  <template v-else>
297
+ <!-- The planner's human gate: the plan below is drafted but NOT yet committed. This
298
+ is the only surface that can resolve it (the generic approval panel is never
299
+ reached — the planner's archetype routes its park to this window), so approve /
300
+ request changes live here, beside the plan they judge. -->
301
+ <section
302
+ v-if="planApproval"
303
+ class="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3.5"
304
+ data-testid="initiative-plan-review"
305
+ >
306
+ <div class="flex items-start gap-2.5">
307
+ <UIcon
308
+ name="i-lucide-clipboard-check"
309
+ class="mt-0.5 h-4 w-4 shrink-0 text-amber-300"
310
+ />
311
+ <div class="min-w-0 flex-1">
312
+ <h3 class="text-[13px] font-semibold text-amber-200">
313
+ {{ t('initiative.planReview.title') }}
314
+ </h3>
315
+ <p class="mt-0.5 text-[12px] leading-relaxed text-amber-100/80">
316
+ {{ t('initiative.planReview.body') }}
317
+ </p>
318
+ <div v-if="!requestingChanges" class="mt-2.5 flex flex-wrap gap-2">
319
+ <button
320
+ class="rounded bg-indigo-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
321
+ :disabled="resolvingPlan || !access.canExecuteRuns.value"
322
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
323
+ data-testid="initiative-plan-approve"
324
+ @click="approvePlan"
325
+ >
326
+ {{ t('initiative.planReview.approve') }}
327
+ </button>
328
+ <button
329
+ class="rounded border border-amber-400/50 px-2.5 py-1 text-[11px] font-medium text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
330
+ :disabled="resolvingPlan || !access.canExecuteRuns.value"
331
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
332
+ data-testid="initiative-plan-request-changes"
333
+ @click="requestingChanges = true"
334
+ >
335
+ {{ t('initiative.planReview.requestChanges') }}
336
+ </button>
337
+ </div>
338
+ <!-- Request-changes composer: the feedback is what the planner re-plans FROM,
339
+ so it is required — an empty send would re-run the planner with nothing
340
+ to act on and park again on the same plan. -->
341
+ <div v-else class="mt-2.5">
342
+ <UTextarea
343
+ v-model="planFeedback"
344
+ :rows="3"
345
+ autoresize
346
+ size="sm"
347
+ class="w-full"
348
+ data-testid="initiative-plan-feedback"
349
+ :placeholder="t('initiative.planReview.feedbackPlaceholder')"
350
+ />
351
+ <div class="mt-2 flex flex-wrap gap-2">
352
+ <button
353
+ class="rounded bg-amber-500 px-2.5 py-1 text-[11px] font-medium text-slate-950 hover:bg-amber-400 disabled:opacity-50"
354
+ :disabled="
355
+ resolvingPlan || !canRequestChanges || !access.canExecuteRuns.value
356
+ "
357
+ :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
358
+ data-testid="initiative-plan-send-back"
359
+ @click="submitPlanChanges"
360
+ >
361
+ {{ t('initiative.planReview.sendBack') }}
362
+ </button>
363
+ <button
364
+ class="rounded border border-slate-600 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:bg-slate-800 disabled:opacity-50"
365
+ :disabled="resolvingPlan"
366
+ data-testid="initiative-plan-cancel-changes"
367
+ @click="requestingChanges = false"
368
+ >
369
+ {{ t('common.cancel') }}
370
+ </button>
371
+ </div>
372
+ </div>
373
+ </div>
374
+ </div>
375
+ </section>
376
+
237
377
  <!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
238
378
  review before the next phase spawns. Read the phase's artifacts/PRs below,
239
379
  then resume (continue) or cancel (stop) the initiative right here. -->
@@ -30,7 +30,6 @@ const documents = useDocumentsStore()
30
30
  const tasks = useTasksStore()
31
31
  const tracker = useTrackerStore()
32
32
  const releaseHealth = useReleaseHealthStore()
33
- const packageRegistries = usePackageRegistriesStore()
34
33
  const publicApiKeys = usePublicApiKeysStore()
35
34
  const userSecrets = useUserSecretsStore()
36
35
  const uiMode = useUiModeStore()
@@ -60,7 +59,6 @@ watch(
60
59
  if (isOpen) {
61
60
  query.value = ''
62
61
  void releaseHealth.ensureLoaded().catch(() => {})
63
- void packageRegistries.ensureLoaded().catch(() => {})
64
62
  void publicApiKeys.ensureLoaded().catch(() => {})
65
63
  void userSecrets.load().catch(() => {})
66
64
  }
@@ -271,22 +269,10 @@ const groups = computed<IntegrationGroup[]>(() => {
271
269
  })
272
270
  }
273
271
 
274
- // --- Development (private package registries + API access tokens) -----------
275
- // Each row is gated like observability: hidden until a probe confirms its module is
276
- // wired (`available === true`), so an unconfigured backend doesn't show a dead row.
272
+ // --- Development (API access tokens) ---------------------------------------
273
+ // Gated like observability: hidden until a probe confirms its module is wired
274
+ // (`available === true`), so an unconfigured backend doesn't show a dead row.
277
275
  const development: IntegrationItem[] = []
278
- if (packageRegistries.available) {
279
- const hasEntries = packageRegistries.entries.length > 0
280
- development.push({
281
- key: 'package-registries',
282
- icon: 'i-lucide-package',
283
- label: t('layout.integrationsHub.items.packageRegistries.label'),
284
- description: t('layout.integrationsHub.items.packageRegistries.description'),
285
- status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
286
- connected: hasEntries,
287
- onClick: () => go(ui.openPackageRegistries),
288
- })
289
- }
290
276
  if (publicApiKeys.available) {
291
277
  const hasKeys = publicApiKeys.keys.length > 0
292
278
  development.push({
@@ -303,8 +289,9 @@ const groups = computed<IntegrationGroup[]>(() => {
303
289
  out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
304
290
 
305
291
  // NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
306
- // warm pool/checkout) is no longer listed here it moved to its OWN top-level navbar menu
307
- // (SideBar → "Infrastructure" → the tabbed Infrastructure window). See `ui.openInfrastructure`.
292
+ // warm pool/checkout + the private package registries a checkout installs from) is no longer
293
+ // listed here — it moved to its OWN top-level navbar menu (SideBar → "Infrastructure" → the
294
+ // tabbed Infrastructure window). See `ui.openInfrastructure`.
308
295
 
309
296
  // --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
310
297
  // Per-user connections normally live in the My-setup hub; with auth disabled they fold in
@@ -6,7 +6,12 @@
6
6
  // with slice 4.
7
7
  import type { Block, InitiativeStatus } from '~/types/domain'
8
8
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
9
- import { INITIATIVE_STATUS_LABEL_KEYS, initiativeProgress } from '~/utils/initiative'
9
+ import {
10
+ INITIATIVE_ATTENTION_ICONS,
11
+ INITIATIVE_ATTENTION_LABEL_KEYS,
12
+ INITIATIVE_STATUS_LABEL_KEYS,
13
+ initiativeProgress,
14
+ } from '~/utils/initiative'
10
15
 
11
16
  const props = defineProps<{ block: Block }>()
12
17
 
@@ -24,6 +29,7 @@ const {
24
29
  running,
25
30
  awaitingAnswers,
26
31
  interviewing,
32
+ attention,
27
33
  starting,
28
34
  runPlanning,
29
35
  openPlanning,
@@ -56,8 +62,23 @@ function control(action: 'pause' | 'resume' | 'cancel') {
56
62
  </p>
57
63
 
58
64
  <div class="flex flex-wrap items-center gap-2">
65
+ <!-- Parked for a human (the drafted plan awaits approval, or an agent raised a decision).
66
+ The same affordance the board card carries, resolved from the same composable — the
67
+ run's park must not be reachable only through the execution panel's step list. -->
59
68
  <UButton
60
- v-if="awaitingAnswers"
69
+ v-if="attention"
70
+ data-testid="initiative-review"
71
+ :data-attention="attention.kind"
72
+ color="warning"
73
+ variant="solid"
74
+ size="sm"
75
+ :icon="INITIATIVE_ATTENTION_ICONS[attention.kind]"
76
+ @click="attention.open()"
77
+ >
78
+ {{ t(INITIATIVE_ATTENTION_LABEL_KEYS[attention.kind]) }}
79
+ </UButton>
80
+ <UButton
81
+ v-else-if="awaitingAnswers"
61
82
  data-testid="initiative-answer-planning"
62
83
  color="primary"
63
84
  variant="solid"
@@ -0,0 +1,100 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ infrastructureTabs,
4
+ openInfrastructureTab,
5
+ repinInfrastructureTab,
6
+ } from './InfrastructureWindow.logic'
7
+
8
+ const NONE = { agents: false, environments: false, packageRegistries: false }
9
+
10
+ describe('infrastructureTabs', () => {
11
+ it('shows nothing when no probe reports a backend', () => {
12
+ expect(infrastructureTabs(NONE)).toEqual([])
13
+ })
14
+
15
+ it('rides the environment probe for shared stacks', () => {
16
+ // A shared stack is infra a Tester environment ATTACHES to, so it cannot stand on its own:
17
+ // without an environment backend there is nothing to attach it to.
18
+ expect(infrastructureTabs({ ...NONE, environments: true })).toEqual([
19
+ 'environment',
20
+ 'shared-stacks',
21
+ ])
22
+ })
23
+
24
+ it('gates the registries tab on its own module probe', () => {
25
+ // The backend 503s the module with no encryption key. That is independent of any execution
26
+ // or test-env backend, so the tab can be the ONLY one a deployment shows.
27
+ expect(infrastructureTabs({ ...NONE, packageRegistries: true })).toEqual(['package-registries'])
28
+ expect(infrastructureTabs({ ...NONE, agents: true })).toEqual(['runner-pool'])
29
+ })
30
+
31
+ it('orders tabs by the question they answer, not by which probe resolved', () => {
32
+ expect(
33
+ infrastructureTabs({ agents: true, environments: true, packageRegistries: true }),
34
+ ).toEqual(['runner-pool', 'environment', 'shared-stacks', 'package-registries'])
35
+ })
36
+ })
37
+
38
+ describe('openInfrastructureTab', () => {
39
+ it('honours an available deep-linked tab over the first one', () => {
40
+ expect(openInfrastructureTab(['runner-pool', 'package-registries'], 'package-registries')).toBe(
41
+ 'package-registries',
42
+ )
43
+ })
44
+
45
+ it('falls back to the first available tab when the request is off', () => {
46
+ expect(openInfrastructureTab(['runner-pool'], 'package-registries')).toBe('runner-pool')
47
+ })
48
+
49
+ it('keeps naming the requested tab when nothing is available yet', () => {
50
+ // Every probe is still in flight on the first open. Holding the request (rather than
51
+ // inventing a selection) is what lets the re-pin land on it the moment its probe resolves.
52
+ expect(openInfrastructureTab([], 'package-registries')).toBe('package-registries')
53
+ })
54
+ })
55
+
56
+ describe('repinInfrastructureTab', () => {
57
+ it('leaves the user where they are when their tab is still available', () => {
58
+ // The list GROWS as probes resolve; someone reading a tab must not be yanked off it.
59
+ expect(
60
+ repinInfrastructureTab(
61
+ ['runner-pool', 'environment', 'package-registries'],
62
+ 'environment',
63
+ 'runner-pool',
64
+ true,
65
+ ),
66
+ ).toBe('environment')
67
+ })
68
+
69
+ it('lands on the deep-linked tab once its probe resolves', () => {
70
+ // The registries probe is the slow one: the window opened on `runner-pool` because
71
+ // `package-registries` was not there yet, and this is the moment it arrives.
72
+ expect(
73
+ repinInfrastructureTab(
74
+ ['runner-pool', 'package-registries'],
75
+ 'shared-stacks',
76
+ 'package-registries',
77
+ true,
78
+ ),
79
+ ).toBe('package-registries')
80
+ })
81
+
82
+ it('does not steal the selection while the probes are still settling', () => {
83
+ // A transient list holding only the fastest probe's tab. Falling back to it here would pin
84
+ // the user to whichever probe won the race and then REJECT the deep link for being a peer
85
+ // of the current tab.
86
+ expect(
87
+ repinInfrastructureTab(['runner-pool'], 'package-registries', 'package-registries', false),
88
+ ).toBe('package-registries')
89
+ })
90
+
91
+ it('falls back to the first tab once loading settled and the request is unavailable', () => {
92
+ expect(
93
+ repinInfrastructureTab(['runner-pool'], 'package-registries', 'package-registries', true),
94
+ ).toBe('runner-pool')
95
+ })
96
+
97
+ it('holds the current tab when settling left nothing available at all', () => {
98
+ expect(repinInfrastructureTab([], 'environment', 'environment', true)).toBe('environment')
99
+ })
100
+ })
@@ -0,0 +1,78 @@
1
+ import type { InfrastructureTab } from '~/types/providerConnections'
2
+
3
+ // Pure tab arithmetic behind `InfrastructureWindow.vue`: which tabs exist for this deployment,
4
+ // and which one to show. Extracted so it is unit-tested directly rather than through the DOM —
5
+ // the window's tabs come from THREE probes that resolve independently (the infrastructure
6
+ // capability, the provider-connection store, the package-registries module), so the interesting
7
+ // behaviour is what happens while the list is still growing, which a rendered-component test
8
+ // reproduces only by accident.
9
+ //
10
+ // Labels stay in the component: a label resolved here would have to travel as a message KEY and
11
+ // be fed to `t()` as a variable, which is exactly what defeats the typed-message-key check. The
12
+ // component maps a tab onto a literal `t()` call through an exhaustive Record instead.
13
+
14
+ /** Which of the window's tabs this deployment can show, as their probes currently read. */
15
+ export interface InfrastructureTabAvailability {
16
+ /** An execution backend is reported (runner pool / container runtime / local). */
17
+ agents: boolean
18
+ /** A test-environment backend is reported. Also gates the shared-stacks tab. */
19
+ environments: boolean
20
+ /** The package-registries module answered its probe affirmatively (it 503s unconfigured). */
21
+ packageRegistries: boolean
22
+ }
23
+
24
+ /**
25
+ * The window's tabs, in display order. Order is the reading order of the questions they answer:
26
+ * where agent containers run, where test environments run, what those environments attach to,
27
+ * and what a checkout may install from.
28
+ *
29
+ * Shared stacks ride the test-environment probe because a stack is infra an environment attaches
30
+ * to — there is nothing to attach without an environment backend.
31
+ */
32
+ export function infrastructureTabs(available: InfrastructureTabAvailability): InfrastructureTab[] {
33
+ const tabs: InfrastructureTab[] = []
34
+ if (available.agents) tabs.push('runner-pool')
35
+ if (available.environments) tabs.push('environment', 'shared-stacks')
36
+ if (available.packageRegistries) tabs.push('package-registries')
37
+ return tabs
38
+ }
39
+
40
+ /**
41
+ * The tab to show when the window OPENS. The deep-linked request wins whenever it is available;
42
+ * otherwise fall back to the first tab that is.
43
+ *
44
+ * Whatever tab was showing last time is deliberately NOT consulted: the caller has just asked
45
+ * for `requested`, and honouring a leftover selection over it would silently ignore the deep
46
+ * link. Falling back to `requested` when nothing is available at all keeps the ref naming the
47
+ * thing that was asked for, so the re-pin below can land on it once its probe resolves.
48
+ */
49
+ export function openInfrastructureTab(
50
+ tabs: readonly InfrastructureTab[],
51
+ requested: InfrastructureTab,
52
+ ): InfrastructureTab {
53
+ if (tabs.includes(requested)) return requested
54
+ return tabs[0] ?? requested
55
+ }
56
+
57
+ /**
58
+ * The tab to show after the tab list CHANGES (a probe resolved). Unlike the open-time choice
59
+ * this one protects the user's current position: a tab list that grows under someone reading a
60
+ * tab must not yank them elsewhere.
61
+ *
62
+ * `settled` is what stops the transient single-tab list from stealing the selection. The probes
63
+ * resolve independently, so the list legitimately passes through states missing tabs that are
64
+ * about to appear; falling back to the first tab before the probes have settled would pin the
65
+ * user to whichever one happened to resolve first, and the deep-linked tab would then be
66
+ * REJECTED for being the current one's peer rather than shown.
67
+ */
68
+ export function repinInfrastructureTab(
69
+ tabs: readonly InfrastructureTab[],
70
+ current: InfrastructureTab,
71
+ requested: InfrastructureTab,
72
+ settled: boolean,
73
+ ): InfrastructureTab {
74
+ if (tabs.includes(current)) return current
75
+ if (tabs.includes(requested)) return requested
76
+ if (settled && tabs.length) return tabs[0]!
77
+ return current
78
+ }