@cat-factory/app 0.173.0 → 0.176.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
@@ -56,10 +56,10 @@ over the WebSocket. How that sync works is written up in
56
56
  ## Interface modes (basic / advanced)
57
57
 
58
58
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
59
- surface: the run/pipeline options that only exist to override a workspace-level default are
60
- left at that default, and the nav is trimmed to the destinations that are the **only route**
61
- to their capability. `advanced` shows everything. The tier resolves in a fixed order, first
62
- match wins:
59
+ **delivery** surface plan work on a board, run it, review and merge it: the run/pipeline
60
+ options that only exist to override a workspace-level default are left at that default, and
61
+ the nav is trimmed to what that loop needs. `advanced` shows everything. The tier resolves in
62
+ a fixed order, first match wins:
63
63
 
64
64
  1. **`NUXT_PUBLIC_UI_MODE`** (`basic` | `advanced`) — the deployment pin. Like
65
65
  `NUXT_PUBLIC_API_BASE` it is baked in at **build** time (`ssr: false`), and while it is
@@ -82,20 +82,32 @@ hoc where it can be avoided:
82
82
  - **A nav destination** declares `advanced: true` in `app/modular/nav-contributions.ts`. The
83
83
  shared `navSlotFilter` drops it in basic mode across all three shells (sidebar, command
84
84
  palette, toolbar), independently of its RBAC `gate` — both must pass. A consumer module's
85
- own contributions take the same flag. The bar for setting it is **route count, not how
86
- advanced the surface feels**: hiding the only way to reach a capability removes the
87
- capability from the tier, while hiding a shortcut into a surface a basic destination also
88
- opens removes nothing. So the flag belongs on a surface that sits beside the delivery path
89
- (Sandbox, Kaizen), on a palette shortcut into a Workspace-settings tab, or on a knob the
90
- Integrations hub already offersand never on a sole route (the pipeline builder, the
91
- fragment library, the infrastructure/PREnv windows, the operator + reports views).
92
- `nav-contributions.spec.ts` pins the advanced set against a table of each item's
93
- alternative route, so promoting one forces that claim to be written down.
85
+ own contributions take the same flag. The bar is **whether the everyday delivery loop needs
86
+ it**, and marking an item does one of two distinguishable things:
87
+ - **Reached another way** a shortcut whose surface a basic destination also opens, so
88
+ nothing is lost (the Merge / Service-best-practices palette entries into Workspace
89
+ settings, the local-models knob the Integrations hub already offers).
90
+ - **Out of the tier** the sole route, hidden on purpose, so the capability is _absent_
91
+ from basic mode and the tier switch is the way to it (Sandbox, Kaizen, repo bootstrap,
92
+ and the deployment-wide operator + reports rollups).
93
+
94
+ Sole-route items stay in basic when the delivery loop runs on them: the pipeline builder,
95
+ add-from-repo, the fragment library, the infrastructure/PREnv windows, and the workspace /
96
+ model configuration a run actually reads. `nav-contributions.spec.ts` pins the advanced set
97
+ against a table naming each item's kind and reason, so promoting one forces that claim to be
98
+ written down rather than assumed.
99
+
94
100
  - **A less-used option inside a surface** reads `useUiModeStore().isAdvanced`. Hide, never
95
101
  disable, and only ever hide an OVERRIDE: what remains must be exactly the default the hidden
96
102
  field would have shown, so a basic-mode user never gets different behaviour from an advanced
97
103
  one — only fewer choices. An input nothing else supplies (the pipeline, the apriori branches)
98
104
  stays in both tiers however advanced it feels.
105
+ - **A whole AUTHORING affordance** may be tier-scoped the same way — the frame header's
106
+ recurring-schedule and initiative buttons are advanced-only — but only while the tier hides
107
+ the ability to CREATE, never the ability to SEE. Existing state has to stay legible in basic
108
+ mode through its normal surfaces (a live schedule still badges its task card and opens its
109
+ inspector panel; an initiative is still a block on the board with its own inspector), or the
110
+ tier turns into a way for a user to be acted on by configuration they cannot find.
99
111
  - **An override control on an EXISTING entity gates on `showOverrideField(isAdvanced, …values)`**
100
112
  (`app/utils/uiMode.ts`) rather than on `isAdvanced` alone. Hiding an override is only safe
101
113
  while it is unset — always true for a creation form, never guaranteed for a block that a
@@ -24,6 +24,7 @@ const agentRuns = useAgentRunsStore()
24
24
  const services = useServicesStore()
25
25
  const reviews = useReviewStage()
26
26
  const access = useWorkspaceAccess()
27
+ const uiMode = useUiModeStore()
27
28
  const { t } = useI18n()
28
29
  const { lod } = useSemanticZoom()
29
30
  // Coarse-pointer (touch) bumps the frame-header actions from `xs` to `sm` so
@@ -161,6 +162,13 @@ function addRecurring() {
161
162
  ui.openAddRecurring(props.id)
162
163
  }
163
164
 
165
+ // Hunt this service's tracker board for a bug worth picking up. Scoped to THIS frame, so
166
+ // an adopted candidate lands here rather than wherever the board's first frame happens to be.
167
+ function huntBugs() {
168
+ ui.expandFrame(props.id)
169
+ ui.openBugHunt(null, props.id)
170
+ }
171
+
164
172
  function createInitiative() {
165
173
  ui.expandFrame(props.id)
166
174
  ui.openCreateInitiative(props.id)
@@ -491,8 +499,18 @@ const ITEM_ICON: Record<string, string> = {
491
499
  :title="t('board.frame.createTaskFromIssueTitle')"
492
500
  @click.stop="createTaskFromIssue"
493
501
  />
502
+ <!-- Recurring pipelines + initiatives are ADVANCED-tier authoring: both plan
503
+ work rather than do it (a schedule that fires runs on a cadence, an
504
+ initiative that groups tasks under a goal), and the basic frame header is
505
+ the most-used control strip on the board. Hiding the CREATE affordance
506
+ removes neither's existing state from basic mode — a live schedule still
507
+ badges its task card and opens its inspector panel, and an initiative is
508
+ still a block on the board with its own inspector — so what a basic-mode
509
+ user loses is the ability to author a new one, not sight of one. -->
494
510
  <UButton
511
+ v-if="uiMode.isAdvanced"
495
512
  class="nodrag"
513
+ data-testid="frame-add-recurring"
496
514
  :size="isTouch ? 'sm' : 'xs'"
497
515
  variant="ghost"
498
516
  color="neutral"
@@ -501,6 +519,7 @@ const ITEM_ICON: Record<string, string> = {
501
519
  @click.stop="addRecurring"
502
520
  />
503
521
  <UButton
522
+ v-if="uiMode.isAdvanced"
504
523
  class="nodrag"
505
524
  data-testid="frame-add-initiative"
506
525
  :size="isTouch ? 'sm' : 'xs'"
@@ -510,6 +529,17 @@ const ITEM_ICON: Record<string, string> = {
510
529
  :title="t('board.frame.createInitiativeTitle')"
511
530
  @click.stop="createInitiative"
512
531
  />
532
+ <UButton
533
+ v-if="tasks.anyOffered"
534
+ class="nodrag"
535
+ data-testid="frame-hunt-bugs"
536
+ :size="isTouch ? 'sm' : 'xs'"
537
+ variant="ghost"
538
+ color="neutral"
539
+ icon="i-lucide-radar"
540
+ :title="t('board.frame.huntBugsTitle')"
541
+ @click.stop="huntBugs"
542
+ />
513
543
  </template>
514
544
  <UButton
515
545
  class="nodrag"
@@ -5,6 +5,7 @@ import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
5
5
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
6
6
  import PipelineProgress from '~/components/pipeline/PipelineProgress.vue'
7
7
  import IconButton from '~/components/common/IconButton.vue'
8
+ import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
8
9
 
9
10
  const board = useBoardStore()
10
11
  const pipelines = usePipelinesStore()
@@ -44,6 +45,20 @@ function runPipeline(id: string) {
44
45
  if (pipeline && block.value) void execution.start(block.value.id, pipeline)
45
46
  }
46
47
 
48
+ /**
49
+ * An initiative block accepts exactly ONE pipeline — its preset's planning pipeline — and the
50
+ * engine refuses every other, so it gets the same single "Run planning" control the board card and
51
+ * the inspector offer rather than a picker whose every other row would be rejected on click. Same
52
+ * composable as those two surfaces, so which pipeline it starts can't drift across the three.
53
+ */
54
+ const isInitiative = computed(() => block.value?.level === 'initiative')
55
+ const {
56
+ planningPipeline,
57
+ running: planningRunning,
58
+ starting: planningStarting,
59
+ runPlanning,
60
+ } = useInitiativePlanning(() => block.value?.id ?? '')
61
+
47
62
  function close() {
48
63
  ui.focus(null)
49
64
  }
@@ -99,9 +114,28 @@ function openApprovalFor(approvalId: string) {
99
114
  {{ statusMeta.label }}
100
115
  </UBadge>
101
116
  <div class="ms-auto flex items-center gap-2">
117
+ <!-- An initiative has one legal pipeline, so it gets the button, not the picker. -->
118
+ <UButton
119
+ v-if="isInitiative"
120
+ color="primary"
121
+ variant="soft"
122
+ size="sm"
123
+ icon="i-lucide-play"
124
+ :loading="planningStarting || planningRunning"
125
+ :disabled="!planningPipeline || planningRunning || planningStarting"
126
+ data-testid="focus-run-planning"
127
+ @click="runPlanning"
128
+ >
129
+ {{ t('initiative.inspector.runPlanning') }}
130
+ </UButton>
102
131
  <!-- The rich picker rather than a list of names: the run starts the moment a row is
103
132
  clicked, so the preview is the only chance to see which agents it will run. -->
104
- <PipelinePicker model-value="" :options="runOptions" @update:model-value="runPipeline">
133
+ <PipelinePicker
134
+ v-else
135
+ model-value=""
136
+ :options="runOptions"
137
+ @update:model-value="runPipeline"
138
+ >
105
139
  <template #trigger>
106
140
  <UButton
107
141
  color="primary"
@@ -127,6 +127,25 @@ async function flushThen(action: (id: string) => Promise<unknown>) {
127
127
 
128
128
  const onContinue = () => flushThen((id) => initiatives.continuePlanning(id))
129
129
  const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
130
+
131
+ /**
132
+ * The escape hatch for a planning run that stalled. It belongs HERE, not only in the inspector's
133
+ * execution panel behind this window: submit and plan-now are the two things that wedge, so the
134
+ * human who needs a way out is looking at exactly this footer. Offered whenever a run still owns
135
+ * the block — including mid-pass and after a failed pass, which is where a wedge actually shows up
136
+ * and where neither of the other two buttons is even rendered.
137
+ *
138
+ * Discarding returns the block to `planned`, which re-enables "Run planning"; the interviewer gate
139
+ * drops the previous run's round bookkeeping on that fresh start, so the re-run genuinely
140
+ * re-interviews instead of force-converging on its first pass. Close on success — leaving the
141
+ * window open on the now-empty idle state would read as another dead end.
142
+ */
143
+ const { resetting, resetRun } = useRunReset()
144
+ const canDiscard = computed(() => !!block.value?.executionId)
145
+ async function onDiscard() {
146
+ if (!blockId.value) return
147
+ if (await resetRun(blockId.value)) close()
148
+ }
130
149
  </script>
131
150
 
132
151
  <template>
@@ -221,28 +240,51 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
221
240
  </template>
222
241
  </div>
223
242
 
224
- <!-- Action rail. Only while the run is actually parked on the human: mid-pass these would
225
- re-submit a question set already in flight, and the resume is a no-op once it isn't. -->
243
+ <!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the
244
+ human: mid-pass they would re-submit a question set already in flight, and the resume is a
245
+ no-op once it isn't. Discard is the opposite — it is offered for as long as a run owns the
246
+ block, because the phases where those two are hidden (working, failed) are exactly the ones
247
+ a wedged run sits in. -->
226
248
  <footer
227
- v-if="initiative && phase === 'awaiting' && questions.length > 0"
249
+ v-if="initiative && (canDiscard || (phase === 'awaiting' && questions.length > 0))"
228
250
  class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
229
251
  >
230
- <p class="text-[11px] text-slate-500">
231
- <span
232
- v-if="unanswered > 0"
233
- class="text-amber-400/90"
234
- data-testid="initiative-planning-unanswered"
235
- >
236
- {{ t('initiative.planning.unanswered', { count: unanswered }) }}
237
- </span>
238
- <span v-else>{{ t('initiative.planning.hint') }}</span>
239
- </p>
240
- <div class="flex items-center gap-2">
252
+ <UButton
253
+ v-if="canDiscard"
254
+ color="error"
255
+ variant="ghost"
256
+ size="sm"
257
+ icon="i-lucide-trash-2"
258
+ :loading="resetting"
259
+ :disabled="resuming"
260
+ :title="t('initiative.planning.discardTitle')"
261
+ data-testid="initiative-planning-discard"
262
+ @click="onDiscard"
263
+ >
264
+ {{ t('initiative.planning.discard') }}
265
+ </UButton>
266
+ <!-- `ms-auto` rather than relying on `justify-between`: discard is conditional, and without
267
+ it this group left-aligns on the (transient) render where it is the only child. -->
268
+ <div
269
+ v-if="phase === 'awaiting' && questions.length > 0"
270
+ class="ms-auto flex items-center gap-2"
271
+ >
272
+ <p class="text-[11px] text-slate-500">
273
+ <span
274
+ v-if="unanswered > 0"
275
+ class="text-amber-400/90"
276
+ data-testid="initiative-planning-unanswered"
277
+ >
278
+ {{ t('initiative.planning.unanswered', { count: unanswered }) }}
279
+ </span>
280
+ <span v-else>{{ t('initiative.planning.hint') }}</span>
281
+ </p>
241
282
  <UButton
242
283
  color="neutral"
243
284
  variant="ghost"
244
285
  size="sm"
245
286
  :loading="resuming"
287
+ :disabled="resetting"
246
288
  :title="t('initiative.planning.proceedTitle')"
247
289
  data-testid="initiative-planning-proceed"
248
290
  @click="onProceed"
@@ -253,7 +295,7 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
253
295
  color="primary"
254
296
  size="sm"
255
297
  :loading="resuming"
256
- :disabled="unanswered > 0"
298
+ :disabled="unanswered > 0 || resetting"
257
299
  :title="
258
300
  unanswered > 0
259
301
  ? t('initiative.planning.unanswered', { count: unanswered })
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { showOverrideField } from '~/utils/uiMode'
3
+
2
4
  // The Integrations hub: a single modal that lists every external system the WORKSPACE can
3
5
  // enable or link in. Each row reuses the existing per-integration panel handlers on the `ui`
4
6
  // store (so the integrations themselves are unchanged); opening one closes the hub and
@@ -25,6 +27,7 @@ const publicApiKeys = usePublicApiKeysStore()
25
27
  const userSecrets = useUserSecretsStore()
26
28
  const apiKeys = useApiKeysStore()
27
29
  const workspace = useWorkspaceStore()
30
+ const uiMode = useUiModeStore()
28
31
 
29
32
  // True when the per-user "My setup" hub is reachable (UserMenu renders only when signed in).
30
33
  // When false (auth disabled / local mode) we fold the personal rows back into this hub so
@@ -268,7 +271,17 @@ const groups = computed<IntegrationGroup[]>(() => {
268
271
  // Gated like every other backend-toggleable system: hidden until a probe confirms
269
272
  // the observability module is enabled (`available === true`), so a disabled backend
270
273
  // doesn't show a dead "Connect" row that only 503s.
271
- if (releaseHealth.available) {
274
+ //
275
+ // Also an ADVANCED-tier row. Post-release health is the one integration here that acts
276
+ // AFTER delivery rather than during it — it watches monitors once a release ships and can
277
+ // spawn an on-call agent — so it sits outside the everyday loop the basic tier serves.
278
+ // Gated with `showOverrideField` rather than a bare `isAdvanced`: an already-connected
279
+ // Datadog is live behaviour on this workspace, and a tier must never conceal a connection a
280
+ // basic-mode user would then have no way to inspect or disconnect.
281
+ if (
282
+ releaseHealth.available &&
283
+ showOverrideField(uiMode.isAdvanced, releaseHealth.connection.connected || null)
284
+ ) {
272
285
  out.push({
273
286
  title: t('layout.integrationsHub.groups.observability'),
274
287
  items: [
@@ -13,7 +13,6 @@ const pipelines = usePipelinesStore()
13
13
  const execution = useExecutionStore()
14
14
  const ui = useUiStore()
15
15
  const documents = useDocumentsStore()
16
- const tasks = useTasksStore()
17
16
  const fragments = useFragmentsStore()
18
17
  const agentRuns = useAgentRunsStore()
19
18
  const github = useGitHubStore()
@@ -60,6 +59,14 @@ watch(
60
59
  )
61
60
  const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
62
61
  const isTask = computed(() => level.value === 'task')
62
+ const isInitiative = computed(() => level.value === 'initiative')
63
+ /**
64
+ * Blocks whose inspector carries a pipeline RUN — a task, and an initiative (whose planning
65
+ * pipeline is an ordinary run of ordinary agent steps). Both get the execution panel and the
66
+ * Focus view; what differs is only how the run is STARTED (a task picks any pipeline, an
67
+ * initiative may only run its planning one, so it keeps its own "Run planning" control).
68
+ */
69
+ const hasRuns = computed(() => isTask.value || isInitiative.value)
63
70
 
64
71
  const instance = computed(() => execution.getInstance(block.value?.executionId))
65
72
  const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
@@ -104,7 +111,10 @@ const runBlockedReason = computed(() => {
104
111
  const canRun = computed(() => runnable.value && access.canExecuteRuns.value)
105
112
 
106
113
  // The delete control names what it removes, so selecting a task and deleting it
107
- // reads as "Delete task" rather than ambiguously removing the whole service.
114
+ // reads as "Delete task" rather than ambiguously removing the whole service. An
115
+ // initiative is its own level (it hangs off a frame like a module does), so it must
116
+ // name ITSELF — offering to "delete service" there describes the wrong blast radius
117
+ // entirely: the frame and every other thing under it survive.
108
118
  const deleteLabel = computed(() =>
109
119
  schedule.value
110
120
  ? t('panels.inspector.deleteRecurringPipeline')
@@ -112,7 +122,9 @@ const deleteLabel = computed(() =>
112
122
  ? t('panels.inspector.deleteTask')
113
123
  : level.value === 'module'
114
124
  ? t('panels.inspector.deleteModule')
115
- : t('panels.inspector.deleteService'),
125
+ : isInitiative.value
126
+ ? t('panels.inspector.deleteInitiative')
127
+ : t('panels.inspector.deleteService'),
116
128
  )
117
129
 
118
130
  // A task is "started" once a pipeline has been launched on it (it has an
@@ -437,33 +449,9 @@ const showOriginalDescription = ref(false)
437
449
  >
438
450
  {{ block!.pullRequest!.branch }}
439
451
  </UButton>
440
- <UButton
441
- v-if="tasks.available"
442
- color="neutral"
443
- variant="soft"
444
- size="xs"
445
- icon="i-lucide-ticket"
446
- @click="ui.openTaskImport()"
447
- >
448
- {{
449
- tasks.anyOffered
450
- ? t('panels.inspector.importIssue')
451
- : t('panels.inspector.connectTracker')
452
- }}
453
- </UButton>
454
- <!-- Hunt this service's tracker board for a bug worth picking up. Scoped to the
455
- selected container, so an adopted candidate lands here rather than wherever the
456
- board's first frame happens to be. -->
457
- <UButton
458
- v-if="isContainer && tasks.anyOffered"
459
- color="neutral"
460
- variant="soft"
461
- size="xs"
462
- icon="i-lucide-radar"
463
- @click="ui.openBugHunt(null, block!.id)"
464
- >
465
- {{ t('panels.inspector.huntBugs') }}
466
- </UButton>
452
+ <!-- Tracker entry points (import an issue, hunt bugs) live on the service frame's
453
+ header, where they are already scoped to a frame — the inspector doesn't
454
+ duplicate them. -->
467
455
  <UButton
468
456
  v-if="isContainer && documents.available && documents.anyConnected"
469
457
  color="neutral"
@@ -543,11 +531,12 @@ const showOriginalDescription = ref(false)
543
531
  </UButton>
544
532
  </UDropdownMenu>
545
533
  <UButton
546
- v-if="isTask"
534
+ v-if="hasRuns"
547
535
  color="neutral"
548
536
  variant="soft"
549
537
  size="sm"
550
538
  icon="i-lucide-maximize-2"
539
+ data-testid="inspector-focus"
551
540
  @click="ui.focus(block.id)"
552
541
  >
553
542
  {{ t('panels.inspector.focus') }}
@@ -22,72 +22,64 @@ function addTask() {
22
22
  </script>
23
23
 
24
24
  <template>
25
+ <!-- Modules and tasks are two peer collapsible lists, not one "Contents" group: the
26
+ grouping added a level of nesting without adding information, and a service's task
27
+ list is long enough to want collapsing independently of its modules. -->
25
28
  <InspectorSection
26
- :title="t('inspector.container.title')"
27
- :hint="t('inspector.container.hint')"
28
- :count="tasks.length"
29
+ v-if="modules.length"
30
+ :title="t('inspector.container.modulesTitle')"
31
+ :hint="t('inspector.container.modulesHint')"
32
+ :count="modules.length"
29
33
  default-open
30
34
  >
31
- <!-- modules (services only) -->
32
- <div v-if="modules.length">
33
- <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
34
- {{ t('inspector.container.modules', { count: modules.length }) }}
35
- </div>
36
- <ul class="space-y-1">
37
- <li
38
- v-for="m in modules"
39
- :key="m.id"
40
- class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
41
- @click="ui.select(m.id)"
42
- >
43
- <UIcon name="i-lucide-package" class="h-3.5 w-3.5 text-violet-400" />
44
- <span class="truncate text-xs text-slate-200">{{ m.title }}</span>
45
- <span class="ms-auto text-[10px] text-slate-500">{{
46
- t(
47
- 'inspector.container.taskCount',
48
- { count: board.tasksOf(m.id).length },
49
- board.tasksOf(m.id).length,
50
- )
51
- }}</span>
52
- </li>
53
- </ul>
54
- </div>
35
+ <ul class="space-y-1">
36
+ <li
37
+ v-for="m in modules"
38
+ :key="m.id"
39
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
40
+ @click="ui.select(m.id)"
41
+ >
42
+ <UIcon name="i-lucide-package" class="h-3.5 w-3.5 text-violet-400" />
43
+ <span class="truncate text-xs text-slate-200">{{ m.title }}</span>
44
+ <span class="ms-auto text-[10px] text-slate-500">{{
45
+ t(
46
+ 'inspector.container.taskCount',
47
+ { count: board.tasksOf(m.id).length },
48
+ board.tasksOf(m.id).length,
49
+ )
50
+ }}</span>
51
+ </li>
52
+ </ul>
53
+ </InspectorSection>
55
54
 
56
- <div>
57
- <div class="mb-1 flex items-center justify-between">
58
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
59
- {{
60
- isFrame
61
- ? t('inspector.container.allTasks', { count: tasks.length })
62
- : t('inspector.container.tasks', { count: tasks.length })
63
- }}
64
- </span>
65
- <div class="flex items-center gap-1.5">
66
- <ReviewDebtBadge />
67
- <UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
68
- {{ t('inspector.container.addTask') }}
69
- </UButton>
70
- </div>
71
- </div>
72
- <ul v-if="tasks.length" class="space-y-1">
73
- <li
74
- v-for="task in tasks"
75
- :key="task.id"
76
- class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
77
- @click="ui.select(task.id)"
78
- >
79
- <span
80
- class="h-2 w-2 shrink-0 rounded-full"
81
- :style="{ backgroundColor: STATUS_META[task.status].color }"
82
- />
83
- <span class="truncate text-xs text-slate-200">{{ task.title }}</span>
84
- <span class="ms-auto text-[10px] text-slate-500">{{
85
- STATUS_META[task.status].label
86
- }}</span>
87
- </li>
88
- </ul>
89
- <div v-else class="text-[11px] text-slate-500">{{ t('inspector.container.noTasks') }}</div>
90
- </div>
55
+ <InspectorSection
56
+ :title="isFrame ? t('inspector.container.allTasksTitle') : t('inspector.container.tasksTitle')"
57
+ :hint="t('inspector.container.tasksHint')"
58
+ :count="tasks.length"
59
+ default-open
60
+ >
61
+ <template #actions>
62
+ <ReviewDebtBadge />
63
+ <UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
64
+ {{ t('inspector.container.addTask') }}
65
+ </UButton>
66
+ </template>
67
+ <ul v-if="tasks.length" class="space-y-1">
68
+ <li
69
+ v-for="task in tasks"
70
+ :key="task.id"
71
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1 hover:bg-slate-800/60"
72
+ @click="ui.select(task.id)"
73
+ >
74
+ <span
75
+ class="h-2 w-2 shrink-0 rounded-full"
76
+ :style="{ backgroundColor: STATUS_META[task.status].color }"
77
+ />
78
+ <span class="truncate text-xs text-slate-200">{{ task.title }}</span>
79
+ <span class="ms-auto text-[10px] text-slate-500">{{ STATUS_META[task.status].label }}</span>
80
+ </li>
81
+ </ul>
82
+ <div v-else class="text-[11px] text-slate-500">{{ t('inspector.container.noTasks') }}</div>
91
83
  <p v-if="isFrame" class="text-[11px] text-slate-500">
92
84
  {{ t('inspector.container.servicesHint') }}
93
85
  </p>
@@ -2,6 +2,7 @@
2
2
  import { computed, reactive, ref, watch } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import { showOverrideField } from '~/utils/uiMode'
5
6
 
6
7
  // Per-service (frame) post-release-health mapping: which observability monitors/SLOs the
7
8
  // `post-release-health` gate watches after this service's PRs ship. Keyed by THIS block's
@@ -11,6 +12,7 @@ const props = defineProps<{ block: Block }>()
11
12
 
12
13
  const store = useReleaseHealthStore()
13
14
  const ui = useUiStore()
15
+ const uiMode = useUiModeStore()
14
16
  const toast = useToast()
15
17
  const { t } = useI18n()
16
18
  const { confirmAction, toastDone } = useConfirmAction()
@@ -21,6 +23,14 @@ const draft = reactive({ monitorIds: '', sloIds: '', envTag: '' })
21
23
  const connected = computed(() => store.connection.connected)
22
24
  const saved = computed(() => store.configForBlock(props.block.id))
23
25
 
26
+ // Post-release health is an ADVANCED-tier concern: with no mapping the gate is a pass-through,
27
+ // so basic mode hides the whole section rather than showing a service-level knob whose absence
28
+ // changes nothing. `showOverrideField` (not a bare `isAdvanced`) because the mapping is per
29
+ // SERVICE and durable — once a teammate on the advanced tier maps monitors onto this frame,
30
+ // hiding the panel would leave a basic-mode user with releases being watched (and an on-call
31
+ // agent that can spawn) by a configuration they can neither see nor clear.
32
+ const show = computed(() => showOverrideField(uiMode.isAdvanced, saved.value))
33
+
24
34
  function parseIds(csv: string): string[] {
25
35
  return csv
26
36
  .split(',')
@@ -91,6 +101,7 @@ async function clear() {
91
101
 
92
102
  <template>
93
103
  <InspectorSection
104
+ v-if="show"
94
105
  :title="t('inspector.releaseHealth.title')"
95
106
  :hint="t('inspector.releaseHealth.sectionHint')"
96
107
  >
@@ -192,26 +192,11 @@ async function stopRun() {
192
192
  stopping.value = false
193
193
  }
194
194
  }
195
- const resetting = ref(false)
196
- async function resetRun() {
197
- if (resetting.value) return
198
- // Destructive: discards the run and returns the task to planned — gate it behind a confirm,
199
- // matching the confirm-then-mutate contract the board delete path uses.
200
- const ok = await confirm({
201
- title: t('inspector.execution.resetConfirm.title'),
202
- description: t('inspector.execution.resetConfirm.body'),
203
- variant: 'destructive',
204
- confirmLabel: t('inspector.execution.resetConfirm.confirm'),
205
- icon: 'i-lucide-trash-2',
206
- })
207
- if (!ok) return
208
- resetting.value = true
209
- try {
210
- await execution.cancel(props.block.id)
211
- } finally {
212
- resetting.value = false
213
- }
214
- }
195
+ // Destructive: discards the run and returns the block to planned, behind a confirm. Shared with
196
+ // the initiative planning window (which offers the same escape hatch in place) so the two can't
197
+ // drift on the prompt or on what "discard" means.
198
+ const { resetting, resetRun: discardRun } = useRunReset()
199
+ const resetRun = () => discardRun(props.block.id)
215
200
 
216
201
  /**
217
202
  * The reviewer-effort tag for this merge, preselected from evidence rather than starting blank: if
@@ -76,7 +76,14 @@ export function useBlockDeletion() {
76
76
  ? 'task'
77
77
  : block.level === 'module'
78
78
  ? 'module'
79
- : 'service'
79
+ : // An initiative names itself rather than falling through to the service copy, which
80
+ // would describe a blast radius orders of magnitude larger than the real one. Its
81
+ // cascade is also genuinely different from a container's: the plan goes with it, but
82
+ // the tasks its loop already spawned are NOT descendants — the backend only detaches
83
+ // their membership link — so the count branch below deliberately doesn't apply.
84
+ block.level === 'initiative'
85
+ ? 'initiative'
86
+ : 'service'
80
87
  const title = t(`panels.inspector.confirmDelete.${kind}.title`)
81
88
  // For a container (service/module) state the exact cascade size so the blast radius is
82
89
  // explicit — "and everything inside it" hides how many tasks/modules go with it.