@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.
@@ -166,6 +166,18 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
166
166
  run: (ui) => ui.openGitHub(),
167
167
  },
168
168
  },
169
+ // Opt-in review-debt friction. In the normal task-create flow AddTaskModal intercepts these
170
+ // 409s and opens the friction dialog (which can retry with an acknowledgement), so these entries
171
+ // are the last-resort toast fallback for any OTHER caller — a generic, param-free title +
172
+ // description reusing the dialog's own `errors.reviewFriction.*` namespace.
173
+ review_debt_warn: {
174
+ titleKey: 'errors.reviewFriction.warnTitle',
175
+ descriptionKey: 'errors.reviewFriction.warnToast',
176
+ },
177
+ review_debt_blocked: {
178
+ titleKey: 'errors.reviewFriction.blockedTitle',
179
+ descriptionKey: 'errors.reviewFriction.blockedToast',
180
+ },
169
181
  }
170
182
 
171
183
  /**
@@ -186,200 +198,231 @@ export function parseConflict(
186
198
  }
187
199
  }
188
200
 
201
+ /** The non-null parsed shape of a backend conflict, as returned by {@link parseConflict}. */
202
+ type ParsedConflict = NonNullable<ReturnType<typeof parseConflict>>
203
+
189
204
  export function usePipelineErrorToast() {
190
205
  const toast = useToast()
191
206
  const ui = useUiStore()
192
207
  const { t, te } = useI18n()
193
208
 
194
- /**
195
- * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
196
- * non-conflict failures and any conflict reason without a dedicated title.
197
- */
198
- function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
199
- const conflict = parseConflict(error)
209
+ // The headline case: a pipeline step's model has no usable provider. Name the
210
+ // offending model(s), explain no provider is available, and offer the one-click jump
211
+ // to the AI setup the same remedy the startup "No AI model configured" banner gives.
212
+ function presentProvidersUnconfigured(conflict: ParsedConflict): void {
213
+ const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
214
+ const list = models.join(', ')
215
+ toast.add({
216
+ title: t('errors.conflict.providersUnconfigured.title'),
217
+ description: list
218
+ ? t('errors.conflict.providersUnconfigured.body', { models: list })
219
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
220
+ color: 'error',
221
+ icon: 'i-lucide-cpu',
222
+ // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
223
+ // auto-dismiss takes the one-click fix with it before the user can reach it.
224
+ duration: 0,
225
+ actions: [
226
+ {
227
+ label: t('errors.conflict.providersUnconfigured.action'),
228
+ icon: 'i-lucide-settings',
229
+ onClick: () => ui.openAiProviderSetup(),
230
+ },
231
+ ],
232
+ })
233
+ }
200
234
 
201
- // The headline case: a pipeline step's model has no usable provider. Name the
202
- // offending model(s), explain no provider is available, and offer the one-click jump
203
- // to the AI setup — the same remedy the startup "No AI model configured" banner gives.
204
- if (conflict?.reason === 'providers_unconfigured') {
205
- const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
206
- const list = models.join(', ')
207
- toast.add({
208
- title: t('errors.conflict.providersUnconfigured.title'),
209
- description: list
210
- ? t('errors.conflict.providersUnconfigured.body', { models: list })
211
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
212
- color: 'error',
213
- icon: 'i-lucide-cpu',
214
- // Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
215
- // auto-dismiss takes the one-click fix with it before the user can reach it.
216
- duration: 0,
217
- actions: [
218
- {
219
- label: t('errors.conflict.providersUnconfigured.action'),
220
- icon: 'i-lucide-settings',
221
- onClick: () => ui.openAiProviderSetup(),
222
- },
223
- ],
224
- })
225
- return
226
- }
235
+ // A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
236
+ // but the account has none configured. Explain it and offer the jump to the content-storage
237
+ // settings — the same shape as the providers-unconfigured case above. Prefer the localized
238
+ // body (it carries no runtime interpolation) so non-English users see translated copy; the
239
+ // raw backend prose is only the last-resort fallback when the locale lacks the key.
240
+ function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
241
+ toast.add({
242
+ title: t('errors.conflict.binaryStorageUnconfigured.title'),
243
+ description: te('errors.conflict.binaryStorageUnconfigured.body')
244
+ ? t('errors.conflict.binaryStorageUnconfigured.body')
245
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
246
+ color: 'error',
247
+ icon: 'i-lucide-image',
248
+ // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
249
+ // remedy reachable instead of letting it auto-dismiss.
250
+ duration: 0,
251
+ actions: [
252
+ {
253
+ label: t('errors.conflict.binaryStorageUnconfigured.action'),
254
+ icon: 'i-lucide-settings',
255
+ onClick: () => ui.openContentStorageSettings(),
256
+ },
257
+ ],
258
+ })
259
+ }
227
260
 
228
- // A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
229
- // but the account has none configured. Explain it and offer the jump to the content-storage
230
- // settings — the same shape as the providers-unconfigured case above. Prefer the localized
231
- // body (it carries no runtime interpolation) so non-English users see translated copy; the
232
- // raw backend prose is only the last-resort fallback when the locale lacks the key.
233
- if (conflict?.reason === 'binary_storage_unconfigured') {
234
- toast.add({
235
- title: t('errors.conflict.binaryStorageUnconfigured.title'),
236
- description: te('errors.conflict.binaryStorageUnconfigured.body')
237
- ? t('errors.conflict.binaryStorageUnconfigured.body')
238
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
239
- color: 'error',
240
- icon: 'i-lucide-image',
241
- // Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
242
- // remedy reachable instead of letting it auto-dismiss.
243
- duration: 0,
244
- actions: [
245
- {
246
- label: t('errors.conflict.binaryStorageUnconfigured.action'),
247
- icon: 'i-lucide-settings',
248
- onClick: () => ui.openContentStorageSettings(),
261
+ // A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
262
+ // "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
263
+ // environment config — the compose wizard for docker-compose, the service inspector otherwise
264
+ // falling back to the workspace infrastructure window if the frame id wasn't carried.
265
+ function presentDeployerServiceConfig(conflict: ParsedConflict): void {
266
+ const frameId =
267
+ typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
268
+ const provisionType =
269
+ typeof conflict.details.provisionType === 'string'
270
+ ? conflict.details.provisionType
271
+ : undefined
272
+ const missing = Array.isArray(conflict.details.missing)
273
+ ? conflict.details.missing.join(', ')
274
+ : ''
275
+ toast.add({
276
+ title: t('errors.conflict.deployerServiceConfig.title'),
277
+ description: missing
278
+ ? t('errors.conflict.deployerServiceConfig.body', { missing })
279
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
280
+ color: 'error',
281
+ icon: 'i-lucide-server',
282
+ // Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
283
+ duration: 0,
284
+ actions: [
285
+ {
286
+ label: t('errors.conflict.deployerServiceConfig.action'),
287
+ icon: 'i-lucide-settings',
288
+ onClick: () => {
289
+ if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
290
+ else if (frameId) ui.select(frameId)
291
+ else ui.openProviderConnection('environment')
249
292
  },
250
- ],
251
- })
252
- return
253
- }
293
+ },
294
+ ],
295
+ })
296
+ }
254
297
 
255
- // A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
256
- // "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
257
- // environment config the compose wizard for docker-compose, the service inspector otherwise
258
- // falling back to the workspace infrastructure window if the frame id wasn't carried.
259
- if (conflict?.reason === 'deployer_service_provisioning_incomplete') {
260
- const frameId =
261
- typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
262
- const provisionType =
263
- typeof conflict.details.provisionType === 'string'
264
- ? conflict.details.provisionType
265
- : undefined
266
- const missing = Array.isArray(conflict.details.missing)
267
- ? conflict.details.missing.join(', ')
268
- : ''
269
- toast.add({
270
- title: t('errors.conflict.deployerServiceConfig.title'),
271
- description: missing
272
- ? t('errors.conflict.deployerServiceConfig.body', { missing })
273
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
274
- color: 'error',
275
- icon: 'i-lucide-server',
276
- // Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
277
- duration: 0,
278
- actions: [
279
- {
280
- label: t('errors.conflict.deployerServiceConfig.action'),
281
- icon: 'i-lucide-settings',
282
- onClick: () => {
283
- if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
284
- else if (frameId) ui.select(frameId)
285
- else ui.openProviderConnection('environment')
286
- },
287
- },
288
- ],
289
- })
290
- return
291
- }
298
+ // A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
299
+ // resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
300
+ // window's Test-environments tab. (Also raised by the Tester start gate same fix applies.)
301
+ function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
302
+ const type =
303
+ typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
304
+ toast.add({
305
+ title: t('errors.conflict.provisionTypeUnhandled.title'),
306
+ description: type
307
+ ? t('errors.conflict.provisionTypeUnhandled.body', { type })
308
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
309
+ color: 'error',
310
+ icon: 'i-lucide-server-cog',
311
+ duration: 0,
312
+ actions: [
313
+ {
314
+ label: t('errors.conflict.provisionTypeUnhandled.action'),
315
+ icon: 'i-lucide-settings',
316
+ onClick: () => ui.openProviderConnection('environment'),
317
+ },
318
+ ],
319
+ })
320
+ }
292
321
 
293
- // A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
294
- // resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
295
- // window's Test-environments tab. (Also raised by the Tester start gate same fix applies.)
296
- if (conflict?.reason === 'provision_type_unhandled') {
297
- const type =
298
- typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
299
- toast.add({
300
- title: t('errors.conflict.provisionTypeUnhandled.title'),
301
- description: type
302
- ? t('errors.conflict.provisionTypeUnhandled.body', { type })
303
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
304
- color: 'error',
305
- icon: 'i-lucide-server-cog',
306
- duration: 0,
307
- actions: [
308
- {
309
- label: t('errors.conflict.provisionTypeUnhandled.action'),
310
- icon: 'i-lucide-settings',
311
- onClick: () => ui.openProviderConnection('environment'),
312
- },
313
- ],
314
- })
315
- return
322
+ // A pipeline includes a Deployer, the config is structurally complete, but the live connection
323
+ // probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
324
+ // token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
325
+ function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
326
+ const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
327
+ toast.add({
328
+ title: t('errors.conflict.deployerConnectionFailed.title'),
329
+ description: detail
330
+ ? t('errors.conflict.deployerConnectionFailed.body', { detail })
331
+ : (conflict.message ?? t('errors.conflict.fallbackMessage')),
332
+ color: 'error',
333
+ icon: 'i-lucide-plug',
334
+ duration: 0,
335
+ actions: [
336
+ {
337
+ label: t('errors.conflict.deployerConnectionFailed.action'),
338
+ icon: 'i-lucide-settings',
339
+ onClick: () => ui.openProviderConnection('environment'),
340
+ },
341
+ ],
342
+ })
343
+ }
344
+
345
+ /**
346
+ * Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
347
+ * each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
348
+ * the reason was one of them (and the toast was raised), `false` to fall through to the generic
349
+ * map. The reason values are mutually exclusive, so dispatch order is irrelevant.
350
+ */
351
+ function presentBespokeConflict(conflict: ParsedConflict): boolean {
352
+ switch (conflict.reason) {
353
+ case 'providers_unconfigured':
354
+ presentProvidersUnconfigured(conflict)
355
+ return true
356
+ case 'binary_storage_unconfigured':
357
+ presentBinaryStorageUnconfigured(conflict)
358
+ return true
359
+ case 'deployer_service_provisioning_incomplete':
360
+ presentDeployerServiceConfig(conflict)
361
+ return true
362
+ case 'provision_type_unhandled':
363
+ presentProvisionTypeUnhandled(conflict)
364
+ return true
365
+ case 'deployer_connection_test_failed':
366
+ presentDeployerConnectionFailed(conflict)
367
+ return true
368
+ default:
369
+ return false
316
370
  }
371
+ }
317
372
 
318
- // A pipeline includes a Deployer, the config is structurally complete, but the live connection
319
- // probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
320
- // token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
321
- if (conflict?.reason === 'deployer_connection_test_failed') {
322
- const detail =
323
- typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
373
+ /**
374
+ * Per-reason copy from the exhaustive map: a translated title + description, and a jump
375
+ * action for the reasons a UI panel can fix. `te` (translation-exists) guards every lookup,
376
+ * so a key missing from the active locale falls back rather than leaking a raw key: the
377
+ * title falls to the caller's key, the description to the raw backend `message`. An unknown
378
+ * reason (not in the map) gets the same generic title + raw-message fallback.
379
+ */
380
+ function presentMappedConflict(conflict: ParsedConflict, fallbackTitleKey: string): void {
381
+ const info = conflict.reason
382
+ ? CONFLICT_INFO[conflict.reason as Exclude<ConflictReason, BespokeConflictReason>]
383
+ : undefined
384
+ if (info) {
324
385
  toast.add({
325
- title: t('errors.conflict.deployerConnectionFailed.title'),
326
- description: detail
327
- ? t('errors.conflict.deployerConnectionFailed.body', { detail })
386
+ title: te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey),
387
+ description: te(info.descriptionKey)
388
+ ? t(info.descriptionKey)
328
389
  : (conflict.message ?? t('errors.conflict.fallbackMessage')),
329
- color: 'error',
330
- icon: 'i-lucide-plug',
331
- duration: 0,
332
- actions: [
333
- {
334
- label: t('errors.conflict.deployerConnectionFailed.action'),
335
- icon: 'i-lucide-settings',
336
- onClick: () => ui.openProviderConnection('environment'),
337
- },
338
- ],
390
+ color: 'warning',
391
+ icon: 'i-lucide-triangle-alert',
392
+ // A reason with a jump action becomes an actionable, sticky toast (like the bespoke
393
+ // conflicts above) so the one-click remedy doesn't auto-dismiss before it's reached.
394
+ ...(info.action
395
+ ? {
396
+ duration: 0,
397
+ actions: [
398
+ {
399
+ label: t(info.action.labelKey),
400
+ icon: info.action.icon,
401
+ onClick: () => info.action?.run(ui),
402
+ },
403
+ ],
404
+ }
405
+ : {}),
339
406
  })
340
407
  return
341
408
  }
409
+ toast.add({
410
+ title: t(fallbackTitleKey),
411
+ description: conflict.message ?? t('errors.conflict.fallbackMessage'),
412
+ color: 'warning',
413
+ icon: 'i-lucide-triangle-alert',
414
+ })
415
+ }
342
416
 
417
+ /**
418
+ * Present `error` as a toast. `fallbackTitleKey` is an i18n message key used for
419
+ * non-conflict failures and any conflict reason without a dedicated title.
420
+ */
421
+ function present(error: unknown, fallbackTitleKey = 'common.actionFailed'): void {
422
+ const conflict = parseConflict(error)
343
423
  if (conflict) {
344
- // Per-reason copy from the exhaustive map: a translated title + description, and a jump
345
- // action for the reasons a UI panel can fix. `te` (translation-exists) guards every lookup,
346
- // so a key missing from the active locale falls back rather than leaking a raw key: the
347
- // title falls to the caller's key, the description to the raw backend `message`. An unknown
348
- // reason (not in the map) gets the same generic title + raw-message fallback.
349
- const info = conflict.reason
350
- ? CONFLICT_INFO[conflict.reason as Exclude<ConflictReason, BespokeConflictReason>]
351
- : undefined
352
- if (info) {
353
- toast.add({
354
- title: te(info.titleKey) ? t(info.titleKey) : t(fallbackTitleKey),
355
- description: te(info.descriptionKey)
356
- ? t(info.descriptionKey)
357
- : (conflict.message ?? t('errors.conflict.fallbackMessage')),
358
- color: 'warning',
359
- icon: 'i-lucide-triangle-alert',
360
- // A reason with a jump action becomes an actionable, sticky toast (like the bespoke
361
- // conflicts above) so the one-click remedy doesn't auto-dismiss before it's reached.
362
- ...(info.action
363
- ? {
364
- duration: 0,
365
- actions: [
366
- {
367
- label: t(info.action.labelKey),
368
- icon: info.action.icon,
369
- onClick: () => info.action?.run(ui),
370
- },
371
- ],
372
- }
373
- : {}),
374
- })
375
- return
376
- }
377
- toast.add({
378
- title: t(fallbackTitleKey),
379
- description: conflict.message ?? t('errors.conflict.fallbackMessage'),
380
- color: 'warning',
381
- icon: 'i-lucide-triangle-alert',
382
- })
424
+ if (presentBespokeConflict(conflict)) return
425
+ presentMappedConflict(conflict, fallbackTitleKey)
383
426
  return
384
427
  }
385
428
 
@@ -0,0 +1,23 @@
1
+ import { computed } from 'vue'
2
+ import { assessReviewFriction } from '@cat-factory/contracts'
3
+ import { useNotificationsStore } from '~/stores/notifications'
4
+ import { useWorkspaceSettingsStore } from '~/stores/workspaceSettings'
5
+
6
+ /**
7
+ * The client-side review-debt verdict, computed from the SAME pure `assessReviewFriction` the
8
+ * backend enforces with, over the workspace snapshot's open notifications + settings. Lets the
9
+ * add-task affordances pre-warn (a debt badge) BEFORE a create is attempted, so the friction
10
+ * dialog is rarely a surprise. The server stays the authority — this is a hint only. See
11
+ * backend/docs/review-debt-friction.md.
12
+ */
13
+ export function useReviewDebt() {
14
+ const notifications = useNotificationsStore()
15
+ const settings = useWorkspaceSettingsStore()
16
+ const verdict = computed(() =>
17
+ assessReviewFriction(notifications.open, settings.settings, Date.now()),
18
+ )
19
+ const active = computed(() => verdict.value.kind !== 'ok')
20
+ const debtCount = computed(() => (verdict.value.kind === 'ok' ? 0 : verdict.value.debt.length))
21
+ const blocked = computed(() => verdict.value.kind === 'block')
22
+ return { verdict, active, debtCount, blocked }
23
+ }
@@ -17,6 +17,7 @@ import DecisionModal from '~/components/panels/DecisionModal.vue'
17
17
  import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
18
18
  import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
19
19
  import AddTaskModal from '~/components/board/AddTaskModal.vue'
20
+ import ReviewFrictionDialog from '~/components/board/ReviewFrictionDialog.vue'
20
21
  import CreateInitiativeModal from '~/components/board/CreateInitiativeModal.vue'
21
22
  import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
22
23
  import CommandBar from '~/components/layout/CommandBar.vue'
@@ -377,6 +378,7 @@ watch(
377
378
  <AgentStepDetail />
378
379
  <StepResultViewHost />
379
380
  <AddTaskModal />
381
+ <ReviewFrictionDialog v-if="ui.reviewFrictionContext" />
380
382
  <CreateInitiativeModal />
381
383
  <CommandBar />
382
384
  <PersonalCredentialModal />
@@ -73,6 +73,9 @@ export function createBoardMutations(ctx: BoardWriteContext) {
73
73
  agentConfig?: Record<string, string>
74
74
  fragmentIds?: string[]
75
75
  technical?: boolean
76
+ // Opt-in review-debt friction: set on the retry after the human confirms the friction
77
+ // dialog, so a soft `review_debt_warn` 409 is tunnelled through (never a hard block).
78
+ acknowledgeReviewDebt?: boolean
76
79
  },
77
80
  ): Promise<Block | undefined> {
78
81
  if (!getBlock(containerId)) return
@@ -90,6 +93,7 @@ export function createBoardMutations(ctx: BoardWriteContext) {
90
93
  // when a caller doesn't manage fragments at all (then the backend seeds from the service).
91
94
  ...(options?.fragmentIds !== undefined ? { fragmentIds: options.fragmentIds } : {}),
92
95
  ...(options?.technical ? { technical: true } : {}),
96
+ ...(options?.acknowledgeReviewDebt ? { acknowledgeReviewDebt: true } : {}),
93
97
  })
94
98
  upsert(block)
95
99
  return block
@@ -25,6 +25,29 @@ export interface K3sSetupPrefill {
25
25
  insecureSkipTlsVerify?: boolean
26
26
  }
27
27
 
28
+ /** One task waiting on human review, as carried on a review-debt friction 409's details. */
29
+ export interface ReviewDebtRow {
30
+ blockId: string
31
+ /** The task's title, joined in server-side; null when it couldn't be resolved. */
32
+ title: string | null
33
+ /** How long the task has been waiting on review, in minutes. */
34
+ waitingMinutes: number
35
+ }
36
+
37
+ /** The context a review-debt friction dialog renders + acts on (from the parsed 409). */
38
+ export interface ReviewFrictionModalContext {
39
+ /** `warn` = soft friction (the human may proceed); `blocked` = hard block (they may not). */
40
+ kind: 'warn' | 'blocked'
41
+ /** Which hard trigger fired (`count` / `stuck`), for the blocked-tier message. */
42
+ reason?: 'count' | 'stuck'
43
+ /** The count / stuck-minutes threshold that fired, for the message. */
44
+ threshold?: number | null
45
+ /** The waiting tasks, worst-first, each deep-linkable to its block. */
46
+ debt: ReviewDebtRow[]
47
+ /** Retry the create with `acknowledgeReviewDebt` — present only for the soft `warn` tier. */
48
+ onConfirm: (() => void) | null
49
+ }
50
+
28
51
  /** Clears both hub came-from markers; injected into the slices whose `open*` handlers reset them. */
29
52
  type ResetHubReturn = () => void
30
53
 
@@ -167,6 +190,14 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
167
190
  // linked context). The user still confirms pipeline / preset before adding.
168
191
  const addTaskPrefill = ref<AddTaskPrefill | null>(null)
169
192
 
193
+ // Review-debt friction dialog: opened when a task-create request is refused by the opt-in
194
+ // friction gate (`review_debt_warn` / `review_debt_blocked` 409). Carries the parsed conflict
195
+ // details the backend supplied (the waiting tasks + the trigger) so the dialog can list exactly
196
+ // what is in review, and — for the soft `warn` tier — an `onConfirm` the "Create anyway" button
197
+ // runs to retry the create with `acknowledgeReviewDebt`. Null when closed. See
198
+ // backend/docs/review-debt-friction.md.
199
+ const reviewFrictionContext = ref<ReviewFrictionModalContext | null>(null)
200
+
170
201
  // Add-recurring-pipeline modal: the service frame a new recurring pipeline is
171
202
  // being added to, or null when closed (mirrors the add-task flow — a button on
172
203
  // the frame opens it, scoped to that frame).
@@ -232,6 +263,12 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
232
263
  addTaskContainerId.value = null
233
264
  addTaskPrefill.value = null
234
265
  }
266
+ function openReviewFriction(ctx: ReviewFrictionModalContext) {
267
+ reviewFrictionContext.value = ctx
268
+ }
269
+ function closeReviewFriction() {
270
+ reviewFrictionContext.value = null
271
+ }
235
272
  function openAddRecurring(frameId: string) {
236
273
  addRecurringFrameId.value = frameId
237
274
  }
@@ -254,6 +291,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
254
291
  taskImport,
255
292
  addTaskContainerId,
256
293
  addTaskPrefill,
294
+ reviewFrictionContext,
257
295
  addRecurringFrameId,
258
296
  createInitiativeFrameId,
259
297
  openDocumentConnect,
@@ -270,6 +308,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
270
308
  closeTaskImport,
271
309
  openAddTask,
272
310
  closeAddTask,
311
+ openReviewFriction,
312
+ closeReviewFriction,
273
313
  openAddRecurring,
274
314
  closeAddRecurring,
275
315
  openCreateInitiative,
@@ -13,6 +13,10 @@ const DEFAULTS: WorkspaceSettings = {
13
13
  artifactRetentionDays: 14,
14
14
  kaizenEnabled: true,
15
15
  delegateAgentsToRunnerPool: false,
16
+ reviewFrictionMode: 'off',
17
+ reviewFrictionWarnCount: 3,
18
+ reviewFrictionBlockCount: null,
19
+ reviewFrictionBlockStuckMinutes: null,
16
20
  spendCurrency: null,
17
21
  spendMonthlyLimit: null,
18
22
  }
@@ -76,6 +76,7 @@ export type {
76
76
  WorkspaceAccess,
77
77
  WorkspaceMember,
78
78
  TaskLimitMode,
79
+ ReviewFrictionMode,
79
80
  WorkspaceSettings,
80
81
  UpdateWorkspaceSettingsInput,
81
82
  UserSettings,
@@ -738,6 +738,23 @@
738
738
  "review": "Review",
739
739
  "ralph": "Ralph-Schleife"
740
740
  },
741
+ "reviewFriction": {
742
+ "heading": "Review-Rückstau-Bremse",
743
+ "body": "Erschwert das Anlegen neuer Aufgaben, solange fertige Arbeit auf die menschliche Prüfung wartet. Standardmäßig aus. „Warnen“ fügt eine Bestätigung hinzu; „Erzwingen“ verweigert das Anlegen ab einem harten Limit ganz.",
744
+ "mode": "Modus",
745
+ "modes": {
746
+ "off": "Aus",
747
+ "warn": "Warnen (Bestätigung nötig)",
748
+ "enforce": "Erzwingen (harte Sperre)"
749
+ },
750
+ "warnCount": "Warnen ab (Aufgaben in Prüfung)",
751
+ "enforceHint": "„Erzwingen“ verweigert das Anlegen von Aufgaben. Aktiviere unten mindestens einen harten Auslöser.",
752
+ "blockCountToggle": "Sperren, wenn zu viele Aufgaben in Prüfung sind",
753
+ "blockCount": "Sperren ab (Aufgaben in Prüfung)",
754
+ "blockStuckToggle": "Sperren, wenn eine Aufgabe zu lange wartet",
755
+ "blockStuckMinutes": "Sperren nach (Minuten Wartezeit)",
756
+ "needsTrigger": "Der Modus „Erzwingen“ benötigt mindestens einen aktiven harten Auslöser (eine Anzahl oder eine Wartezeit)."
757
+ },
741
758
  "observability": {
742
759
  "heading": "Agenten-Observability",
743
760
  "body": "Speichere den vollständigen Kontext, der jedem Agenten bereitgestellt wird: die zusammengesetzten Prompts, die eingefügten Best-Practice-Fragmente und den vollständigen Inhalt der in seinen Container injizierten Dateien, sodass er später in der Observability-Ansicht inspiziert werden kann. Die Inhalte werden für denselben Zeitraum wie die LLM-Telemetrie pro Aufruf aufbewahrt. Deaktivieren, um die Speicherung zu stoppen (bestehende Snapshots werden durch die Aufbewahrung bereinigt).",
@@ -4060,6 +4077,22 @@
4060
4077
  "mergeFailed": "Zusammenführen fehlgeschlagen",
4061
4078
  "restartFailed": "Neustart fehlgeschlagen"
4062
4079
  },
4080
+ "reviewFriction": {
4081
+ "warnTitle": "Aufgaben warten auf Prüfung",
4082
+ "blockedTitle": "Zu viele Aufgaben warten auf Prüfung",
4083
+ "warnToast": "Einige Aufgaben warten auf die menschliche Prüfung. Prüfe sie, bevor du mehr Arbeit anlegst.",
4084
+ "blockedToast": "Das Anlegen von Aufgaben ist gesperrt, bis der Prüf-Rückstau abgebaut ist.",
4085
+ "warnBody": "{count} Aufgabe(n) warten auf die menschliche Prüfung. Prüfe sie, bevor du mehr Arbeit anlegst.",
4086
+ "blockedCountBody": "Anlegen gesperrt: {count} Aufgabe(n) warten auf die menschliche Prüfung (Limit {threshold}). Baue zuerst den Prüf-Rückstau ab.",
4087
+ "blockedStuckBody": "Anlegen gesperrt: Eine Aufgabe wartet seit über {minutes} Minute(n) auf die menschliche Prüfung. Baue zuerst den Prüf-Rückstau ab.",
4088
+ "waitingHeading": "Warten auf Prüfung",
4089
+ "waiting": "{minutes} Min. Wartezeit",
4090
+ "untitled": "Aufgabe ohne Titel",
4091
+ "close": "Schließen",
4092
+ "createAnyway": "Trotzdem anlegen",
4093
+ "goReview": "Zur Prüfung",
4094
+ "badge": "{count} Aufgabe(n) warten auf Prüfung"
4095
+ },
4063
4096
  "conflict": {
4064
4097
  "title": {
4065
4098
  "dependencies_unmet": "Durch Abhängigkeiten blockiert",