@cat-factory/app 0.150.0 → 0.151.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.
@@ -28,6 +28,7 @@ import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.
28
28
  import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
29
29
  import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
30
30
  import { riskPolicyOptionLabel, riskPolicySummary } from '~/utils/riskPolicy'
31
+ import { parseConflict } from '~/composables/usePipelineErrorToast'
31
32
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
32
33
 
33
34
  const ui = useUiStore()
@@ -678,8 +679,7 @@ const canAdd = computed(() => {
678
679
  })
679
680
 
680
681
  async function add() {
681
- const containerId = ui.addTaskContainerId
682
- if (!containerId || !canAdd.value) return
682
+ if (!canAdd.value) return
683
683
  // Recurring tasks are created via a schedule on the service frame — hand off to the
684
684
  // existing recurring-pipeline modal (which carries the cadence + prompt).
685
685
  if (isRecurring.value) {
@@ -689,6 +689,17 @@ async function add() {
689
689
  ui.openAddRecurring(frameId)
690
690
  return
691
691
  }
692
+ await submitCreate(false)
693
+ }
694
+
695
+ /**
696
+ * Create the task, optionally acknowledging review-debt friction. A `review_debt_*` 409 opens the
697
+ * friction dialog instead of a bare error toast: the soft `warn` tier's dialog can retry via
698
+ * `submitCreate(true)` (the `onConfirm`), while a hard `blocked` tier only offers "Go review".
699
+ */
700
+ async function submitCreate(acknowledgeReviewDebt: boolean) {
701
+ const containerId = ui.addTaskContainerId
702
+ if (!containerId) return
692
703
  saving.value = true
693
704
  try {
694
705
  const typeFields = buildTypeFields()
@@ -718,6 +729,7 @@ async function add() {
718
729
  // re-seeded from the service. The task owns its fragments from here.
719
730
  fragmentIds: [...fragmentIds.value],
720
731
  ...(technical.value ? { technical: true } : {}),
732
+ ...(acknowledgeReviewDebt ? { acknowledgeReviewDebt: true } : {}),
721
733
  })
722
734
  if (block) {
723
735
  // Surface the SPECIFIC cause of any attachment that couldn't be linked (a GitHub
@@ -725,8 +737,14 @@ async function add() {
725
737
  // one-click "Copy details" for a bug report.
726
738
  presentLinkFailures(await linkPending(block.id, pendingContext.value), block.id)
727
739
  }
740
+ ui.closeReviewFriction()
728
741
  ui.closeAddTask()
729
742
  } catch (e) {
743
+ const conflict = parseConflict(e)
744
+ if (conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked') {
745
+ openReviewFrictionDialog(conflict)
746
+ return
747
+ }
730
748
  toast.add({
731
749
  title: t('board.addTask.addFailedTitle'),
732
750
  description: e instanceof Error ? e.message : String(e),
@@ -737,6 +755,29 @@ async function add() {
737
755
  saving.value = false
738
756
  }
739
757
  }
758
+
759
+ /** Turn a parsed review-debt friction 409 into the dialog context (see ReviewFrictionDialog.vue). */
760
+ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseConflict>>) {
761
+ const details = conflict.details
762
+ const rawDebt = Array.isArray(details.debt) ? details.debt : []
763
+ const debt = rawDebt.map((d) => {
764
+ const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
765
+ return {
766
+ blockId: typeof row.blockId === 'string' ? row.blockId : '',
767
+ title: typeof row.title === 'string' ? row.title : null,
768
+ waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
769
+ }
770
+ })
771
+ const isWarn = conflict.reason === 'review_debt_warn'
772
+ ui.openReviewFriction({
773
+ kind: isWarn ? 'warn' : 'blocked',
774
+ reason:
775
+ details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
776
+ threshold: typeof details.threshold === 'number' ? details.threshold : null,
777
+ debt,
778
+ onConfirm: isWarn ? () => void submitCreate(true) : null,
779
+ })
780
+ }
740
781
  </script>
741
782
 
742
783
  <template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ // A small pre-warn badge shown next to an "Add task" affordance when the workspace's opt-in
3
+ // review-debt friction is at (or past) its threshold, so the friction dialog isn't a surprise.
4
+ // Hidden entirely when friction is off or the queue is under the warn threshold. The server
5
+ // remains the authority — this is a hint. See backend/docs/review-debt-friction.md.
6
+ import { computed } from 'vue'
7
+ import { useReviewDebt } from '~/composables/useReviewDebt'
8
+
9
+ const { active, debtCount, blocked } = useReviewDebt()
10
+ const { t } = useI18n()
11
+
12
+ const color = computed(() => (blocked.value ? 'error' : 'warning'))
13
+ </script>
14
+
15
+ <template>
16
+ <UBadge
17
+ v-if="active"
18
+ :color="color"
19
+ variant="subtle"
20
+ size="sm"
21
+ icon="i-lucide-clock"
22
+ :title="t('errors.reviewFriction.badge', { count: debtCount })"
23
+ >
24
+ {{ debtCount }}
25
+ </UBadge>
26
+ </template>
@@ -0,0 +1,118 @@
1
+ <script setup lang="ts">
2
+ // Opt-in review-debt friction dialog. Shown when a task-create request is refused by the friction
3
+ // gate (a `review_debt_warn` / `review_debt_blocked` 409). It lists exactly which tasks are waiting
4
+ // on human review (worst first), each deep-linking to its block so "go review instead" is one
5
+ // click. The soft `warn` tier offers a secondary "Create anyway" (retries with the acknowledge
6
+ // flag); the hard `blocked` tier does not. Driven by `ui.reviewFrictionContext`, mounted in
7
+ // pages/index.vue. See backend/docs/review-debt-friction.md.
8
+ import { computed } from 'vue'
9
+
10
+ const { t } = useI18n()
11
+ const ui = useUiStore()
12
+
13
+ const ctx = computed(() => ui.reviewFrictionContext)
14
+
15
+ const open = computed({
16
+ get: () => ctx.value !== null,
17
+ set: (v: boolean) => {
18
+ if (!v) ui.closeReviewFriction()
19
+ },
20
+ })
21
+
22
+ const title = computed(() =>
23
+ ctx.value?.kind === 'blocked'
24
+ ? t('errors.reviewFriction.blockedTitle')
25
+ : t('errors.reviewFriction.warnTitle'),
26
+ )
27
+
28
+ const body = computed(() => {
29
+ const c = ctx.value
30
+ if (!c) return ''
31
+ if (c.kind === 'warn') return t('errors.reviewFriction.warnBody', { count: c.debt.length })
32
+ if (c.reason === 'stuck')
33
+ return t('errors.reviewFriction.blockedStuckBody', { minutes: c.threshold ?? 0 })
34
+ return t('errors.reviewFriction.blockedCountBody', {
35
+ count: c.debt.length,
36
+ threshold: c.threshold ?? 0,
37
+ })
38
+ })
39
+
40
+ /** Deep-link to a waiting task's block and dismiss the whole friction flow. */
41
+ function goToBlock(blockId: string) {
42
+ if (blockId) ui.select(blockId)
43
+ ui.closeReviewFriction()
44
+ ui.closeAddTask()
45
+ }
46
+
47
+ function goReview() {
48
+ const first = ctx.value?.debt[0]
49
+ if (first) goToBlock(first.blockId)
50
+ else {
51
+ ui.closeReviewFriction()
52
+ ui.closeAddTask()
53
+ }
54
+ }
55
+
56
+ function createAnyway() {
57
+ ctx.value?.onConfirm?.()
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <UModal v-model:open="open" :title="title" :ui="{ content: 'max-w-xl' }">
63
+ <template #body>
64
+ <div v-if="ctx" class="space-y-5">
65
+ <p class="text-sm text-slate-300">{{ body }}</p>
66
+
67
+ <div class="rounded-lg border border-slate-700 bg-slate-900/50 p-2">
68
+ <p class="mb-1.5 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
69
+ {{ t('errors.reviewFriction.waitingHeading') }}
70
+ </p>
71
+ <ul class="space-y-1">
72
+ <li v-for="item in ctx.debt" :key="item.blockId">
73
+ <button
74
+ type="button"
75
+ class="flex w-full items-center justify-between gap-3 rounded-md px-2 py-1.5 text-left text-sm hover:bg-slate-800/60"
76
+ @click="goToBlock(item.blockId)"
77
+ >
78
+ <span class="truncate text-slate-200">
79
+ {{ item.title || t('errors.reviewFriction.untitled') }}
80
+ </span>
81
+ <span class="shrink-0 text-[12px] text-slate-500">
82
+ {{ t('errors.reviewFriction.waiting', { minutes: item.waitingMinutes }) }}
83
+ </span>
84
+ </button>
85
+ </li>
86
+ </ul>
87
+ </div>
88
+
89
+ <div class="flex flex-wrap justify-end gap-2">
90
+ <UButton
91
+ color="neutral"
92
+ variant="ghost"
93
+ size="sm"
94
+ @click="
95
+ () => {
96
+ open = false
97
+ }
98
+ "
99
+ >
100
+ {{ t('errors.reviewFriction.close') }}
101
+ </UButton>
102
+ <UButton
103
+ v-if="ctx.onConfirm"
104
+ color="neutral"
105
+ variant="subtle"
106
+ size="sm"
107
+ @click="createAnyway"
108
+ >
109
+ {{ t('errors.reviewFriction.createAnyway') }}
110
+ </UButton>
111
+ <UButton color="primary" size="sm" icon="i-lucide-list-checks" @click="goReview">
112
+ {{ t('errors.reviewFriction.goReview') }}
113
+ </UButton>
114
+ </div>
115
+ </div>
116
+ </template>
117
+ </UModal>
118
+ </template>
@@ -2,6 +2,7 @@
2
2
  import type { Block } from '~/types/domain'
3
3
  import { STATUS_META } from '~/utils/catalog'
4
4
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
+ import ReviewDebtBadge from '~/components/board/ReviewDebtBadge.vue'
5
6
 
6
7
  const props = defineProps<{ block: Block }>()
7
8
 
@@ -61,9 +62,12 @@ function addTask() {
61
62
  : t('inspector.container.tasks', { count: tasks.length })
62
63
  }}
63
64
  </span>
64
- <UButton size="xs" variant="soft" color="primary" icon="i-lucide-plus" @click="addTask">
65
- {{ t('inspector.container.addTask') }}
66
- </UButton>
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>
67
71
  </div>
68
72
  <ul v-if="tasks.length" class="space-y-1">
69
73
  <li
@@ -77,6 +77,44 @@ const schemeItems = computed(() => [
77
77
  { label: 'http', value: 'http' },
78
78
  ])
79
79
 
80
+ // A stored non-secret value is `unknown`; coerce it to the string the form field holds,
81
+ // defaulting a missing/wrong-typed value to '' (the same `typeof … === 'string' ? … : ''`
82
+ // each field used inline before).
83
+ function readString(v: unknown): string {
84
+ return typeof v === 'string' ? v : ''
85
+ }
86
+
87
+ // Prefill the manifest-source fields from the stored discriminated config.
88
+ function applyManifestSource(k: Record<string, unknown>): void {
89
+ const src = k.manifestSource as Record<string, unknown> | undefined
90
+ if (src?.type === 'separate') {
91
+ form.manifestSourceType = 'separate'
92
+ form.manifestRepo = readString(src.repo)
93
+ form.manifestRef = readString(src.ref)
94
+ form.manifestPath = readString(src.path)
95
+ } else if (src?.type === 'colocated') {
96
+ form.manifestSourceType = 'colocated'
97
+ form.manifestPath = readString(src.path)
98
+ }
99
+ }
100
+
101
+ // Prefill the URL-derivation fields (+ scheme) from the stored discriminated config.
102
+ function applyUrl(k: Record<string, unknown>): void {
103
+ const url = k.url as Record<string, unknown> | undefined
104
+ if (url?.source === 'ingressTemplate') {
105
+ form.urlSource = 'ingressTemplate'
106
+ form.hostTemplate = readString(url.hostTemplate)
107
+ } else if (url?.source === 'ingressStatus') {
108
+ form.urlSource = 'ingressStatus'
109
+ form.ingressName = readString(url.ingressName)
110
+ } else if (url?.source === 'serviceStatus') {
111
+ form.urlSource = 'serviceStatus'
112
+ form.serviceName = readString(url.serviceName)
113
+ form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
114
+ }
115
+ form.urlScheme = url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
116
+ }
117
+
80
118
  // A registered k8s env connection exposes its non-secret config, so prefill every
81
119
  // non-secret field from it (never the token — secrets are write-only and re-entered on
82
120
  // update). This lets an edit change one field without re-typing the whole form.
@@ -89,36 +127,14 @@ watch(
89
127
  ? (c.config as { kubernetes: Record<string, unknown> }).kubernetes
90
128
  : undefined
91
129
  if (!k) return
92
- form.label = typeof k.label === 'string' ? k.label : ''
93
- form.apiServerUrl = typeof k.apiServerUrl === 'string' ? k.apiServerUrl : ''
94
- form.caCertPem = typeof k.caCertPem === 'string' ? k.caCertPem : ''
130
+ form.label = readString(k.label)
131
+ form.apiServerUrl = readString(k.apiServerUrl)
132
+ form.caCertPem = readString(k.caCertPem)
95
133
  form.insecureSkipTlsVerify = k.insecureSkipTlsVerify === true
96
- form.namespaceTemplate = typeof k.namespaceTemplate === 'string' ? k.namespaceTemplate : ''
97
- form.imageTemplate = typeof k.imageTemplate === 'string' ? k.imageTemplate : ''
98
- const src = k.manifestSource as Record<string, unknown> | undefined
99
- if (src?.type === 'separate') {
100
- form.manifestSourceType = 'separate'
101
- form.manifestRepo = typeof src.repo === 'string' ? src.repo : ''
102
- form.manifestRef = typeof src.ref === 'string' ? src.ref : ''
103
- form.manifestPath = typeof src.path === 'string' ? src.path : ''
104
- } else if (src?.type === 'colocated') {
105
- form.manifestSourceType = 'colocated'
106
- form.manifestPath = typeof src.path === 'string' ? src.path : ''
107
- }
108
- const url = k.url as Record<string, unknown> | undefined
109
- if (url?.source === 'ingressTemplate') {
110
- form.urlSource = 'ingressTemplate'
111
- form.hostTemplate = typeof url.hostTemplate === 'string' ? url.hostTemplate : ''
112
- } else if (url?.source === 'ingressStatus') {
113
- form.urlSource = 'ingressStatus'
114
- form.ingressName = typeof url.ingressName === 'string' ? url.ingressName : ''
115
- } else if (url?.source === 'serviceStatus') {
116
- form.urlSource = 'serviceStatus'
117
- form.serviceName = typeof url.serviceName === 'string' ? url.serviceName : ''
118
- form.servicePort = typeof url.port === 'number' ? String(url.port) : ''
119
- }
120
- form.urlScheme =
121
- url && (url.scheme === 'http' || url.scheme === 'https') ? url.scheme : 'default'
134
+ form.namespaceTemplate = readString(k.namespaceTemplate)
135
+ form.imageTemplate = readString(k.imageTemplate)
136
+ applyManifestSource(k)
137
+ applyUrl(k)
122
138
  },
123
139
  { immediate: true },
124
140
  )
@@ -8,7 +8,7 @@
8
8
  // The latter three are body-only section components rendered in tabs here (no longer
9
9
  // standalone modals).
10
10
  import { reactive, ref, watch } from 'vue'
11
- import type { TaskLimitMode } from '~/types/domain'
11
+ import type { ReviewFrictionMode, TaskLimitMode } from '~/types/domain'
12
12
  import RiskPolicyPanel from '~/components/settings/RiskPolicyPanel.vue'
13
13
  import IssueTrackerPanel from '~/components/settings/IssueTrackerPanel.vue'
14
14
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
@@ -129,6 +129,12 @@ const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
129
129
  { value: 'per_type', label: t('settings.workspaceSettings.taskLimit.modes.per_type') },
130
130
  ])
131
131
 
132
+ const REVIEW_FRICTION_MODES = computed<{ value: ReviewFrictionMode; label: string }[]>(() => [
133
+ { value: 'off', label: t('settings.workspaceSettings.reviewFriction.modes.off') },
134
+ { value: 'warn', label: t('settings.workspaceSettings.reviewFriction.modes.warn') },
135
+ { value: 'enforce', label: t('settings.workspaceSettings.reviewFriction.modes.enforce') },
136
+ ])
137
+
132
138
  /** The localized "Max {type} tasks" label for a per-type running-task limit input. */
133
139
  function maxTaskTypeLabel(type: LimitTaskType): string {
134
140
  const key = TASK_TYPE_KEYS[type]
@@ -145,6 +151,12 @@ const draft = reactive({
145
151
  storeAgentContext: true,
146
152
  artifactRetentionDays: 14,
147
153
  kaizenEnabled: true,
154
+ reviewFrictionMode: 'off' as ReviewFrictionMode,
155
+ reviewFrictionWarnCount: 3,
156
+ reviewFrictionBlockCountEnabled: false,
157
+ reviewFrictionBlockCount: 10 as number,
158
+ reviewFrictionBlockStuckEnabled: false,
159
+ reviewFrictionBlockStuckMinutes: 1440 as number,
148
160
  })
149
161
 
150
162
  function hydrate() {
@@ -157,6 +169,14 @@ function hydrate() {
157
169
  draft.storeAgentContext = s.storeAgentContext
158
170
  draft.artifactRetentionDays = s.artifactRetentionDays
159
171
  draft.kaizenEnabled = s.kaizenEnabled
172
+ draft.reviewFrictionMode = s.reviewFrictionMode
173
+ draft.reviewFrictionWarnCount = s.reviewFrictionWarnCount
174
+ // The hard-block knobs are nullable (null ⇒ that trigger is off); a per-trigger checkbox is
175
+ // enabled from whether a value is stored, defaulting the input to a sensible starting number.
176
+ draft.reviewFrictionBlockCountEnabled = s.reviewFrictionBlockCount != null
177
+ draft.reviewFrictionBlockCount = s.reviewFrictionBlockCount ?? 10
178
+ draft.reviewFrictionBlockStuckEnabled = s.reviewFrictionBlockStuckMinutes != null
179
+ draft.reviewFrictionBlockStuckMinutes = s.reviewFrictionBlockStuckMinutes ?? 1440
160
180
  }
161
181
 
162
182
  // `store.settings` is always replaced wholesale (store hydrate/update reassign the ref),
@@ -166,6 +186,25 @@ watch(() => store.settings, hydrate, { immediate: true })
166
186
  const saving = ref(false)
167
187
 
168
188
  async function save() {
189
+ // The hard-block triggers only apply in `enforce` mode; a disabled trigger sends null.
190
+ const blockCount =
191
+ draft.reviewFrictionMode === 'enforce' && draft.reviewFrictionBlockCountEnabled
192
+ ? draft.reviewFrictionBlockCount
193
+ : null
194
+ const blockStuckMinutes =
195
+ draft.reviewFrictionMode === 'enforce' && draft.reviewFrictionBlockStuckEnabled
196
+ ? draft.reviewFrictionBlockStuckMinutes
197
+ : null
198
+ // Mirror the backend's enforce-mode validation client-side so the user gets an immediate,
199
+ // localized message instead of the raw 422 (enforce needs at least one hard trigger).
200
+ if (draft.reviewFrictionMode === 'enforce' && blockCount == null && blockStuckMinutes == null) {
201
+ toast.add({
202
+ title: t('settings.workspaceSettings.reviewFriction.needsTrigger'),
203
+ icon: 'i-lucide-triangle-alert',
204
+ color: 'warning',
205
+ })
206
+ return
207
+ }
169
208
  saving.value = true
170
209
  try {
171
210
  await store.update({
@@ -185,6 +224,10 @@ async function save() {
185
224
  storeAgentContext: draft.storeAgentContext,
186
225
  artifactRetentionDays: draft.artifactRetentionDays,
187
226
  kaizenEnabled: draft.kaizenEnabled,
227
+ reviewFrictionMode: draft.reviewFrictionMode,
228
+ reviewFrictionWarnCount: draft.reviewFrictionWarnCount,
229
+ reviewFrictionBlockCount: blockCount,
230
+ reviewFrictionBlockStuckMinutes: blockStuckMinutes,
188
231
  })
189
232
  toast.add({
190
233
  title: t('settings.workspaceSettings.toast.saved'),
@@ -288,6 +331,80 @@ async function save() {
288
331
  </div>
289
332
  </section>
290
333
 
334
+ <!-- Review-debt friction on task creation -->
335
+ <section class="space-y-2">
336
+ <h3 class="text-sm font-semibold text-slate-200">
337
+ {{ t('settings.workspaceSettings.reviewFriction.heading') }}
338
+ </h3>
339
+ <p class="text-[11px] text-slate-400">
340
+ {{ t('settings.workspaceSettings.reviewFriction.body') }}
341
+ </p>
342
+ <label class="block w-64">
343
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">{{
344
+ t('settings.workspaceSettings.reviewFriction.mode')
345
+ }}</span>
346
+ <USelect
347
+ v-model="draft.reviewFrictionMode"
348
+ :items="REVIEW_FRICTION_MODES"
349
+ value-key="value"
350
+ size="sm"
351
+ class="w-full"
352
+ />
353
+ </label>
354
+
355
+ <label v-if="draft.reviewFrictionMode !== 'off'" class="block w-48">
356
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
357
+ {{ t('settings.workspaceSettings.reviewFriction.warnCount') }}
358
+ </span>
359
+ <UInput
360
+ v-model.number="draft.reviewFrictionWarnCount"
361
+ type="number"
362
+ :min="1"
363
+ size="sm"
364
+ />
365
+ </label>
366
+
367
+ <div v-if="draft.reviewFrictionMode === 'enforce'" class="space-y-2">
368
+ <p class="text-[11px] text-slate-400">
369
+ {{ t('settings.workspaceSettings.reviewFriction.enforceHint') }}
370
+ </p>
371
+ <label class="flex items-center gap-2">
372
+ <USwitch v-model="draft.reviewFrictionBlockCountEnabled" size="sm" />
373
+ <span class="text-[13px] text-slate-300">{{
374
+ t('settings.workspaceSettings.reviewFriction.blockCountToggle')
375
+ }}</span>
376
+ </label>
377
+ <label v-if="draft.reviewFrictionBlockCountEnabled" class="block w-48">
378
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
379
+ {{ t('settings.workspaceSettings.reviewFriction.blockCount') }}
380
+ </span>
381
+ <UInput
382
+ v-model.number="draft.reviewFrictionBlockCount"
383
+ type="number"
384
+ :min="1"
385
+ size="sm"
386
+ />
387
+ </label>
388
+ <label class="flex items-center gap-2">
389
+ <USwitch v-model="draft.reviewFrictionBlockStuckEnabled" size="sm" />
390
+ <span class="text-[13px] text-slate-300">{{
391
+ t('settings.workspaceSettings.reviewFriction.blockStuckToggle')
392
+ }}</span>
393
+ </label>
394
+ <label v-if="draft.reviewFrictionBlockStuckEnabled" class="block w-48">
395
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
396
+ {{ t('settings.workspaceSettings.reviewFriction.blockStuckMinutes') }}
397
+ </span>
398
+ <UInput
399
+ v-model.number="draft.reviewFrictionBlockStuckMinutes"
400
+ type="number"
401
+ :min="1"
402
+ size="sm"
403
+ />
404
+ </label>
405
+ </div>
406
+ </section>
407
+
291
408
  <!-- Agent observability -->
292
409
  <section class="space-y-2">
293
410
  <h3 class="text-sm font-semibold text-slate-200">
@@ -54,6 +54,7 @@ export function boardApi({ send, ws }: ApiContext) {
54
54
  pipelineId?: string
55
55
  agentConfig?: Record<string, string>
56
56
  technical?: boolean
57
+ acknowledgeReviewDebt?: boolean
57
58
  },
58
59
  ) => send(addTaskContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
59
60