@cat-factory/app 0.69.1 → 0.70.1

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.
Files changed (44) hide show
  1. package/app/components/board/AgentFailureCard.vue +3 -0
  2. package/app/components/board/TaskDependencyEdges.vue +66 -0
  3. package/app/components/board/nodes/BlockNode.vue +10 -2
  4. package/app/components/board/nodes/TaskCard.vue +12 -1
  5. package/app/components/common/ConfirmDialog.vue +59 -0
  6. package/app/components/common/EmptyState.vue +35 -0
  7. package/app/components/common/KeyboardShortcutsHelp.vue +48 -0
  8. package/app/components/documents/ContextDocumentPicker.vue +8 -4
  9. package/app/components/fragments/FragmentLibraryManager.vue +10 -0
  10. package/app/components/github/GitHubPanel.vue +9 -0
  11. package/app/components/layout/BoardSwitcher.vue +11 -0
  12. package/app/components/layout/CommandBar.vue +8 -0
  13. package/app/components/layout/NotificationsInbox.vue +11 -0
  14. package/app/components/panels/InspectorPanel.vue +9 -13
  15. package/app/components/panels/StepContainerStatus.vue +37 -0
  16. package/app/components/panels/inspector/FrontendConfig.vue +349 -0
  17. package/app/components/panels/inspector/TaskDependencies.vue +18 -4
  18. package/app/components/panels/inspector/TaskExecution.vue +24 -0
  19. package/app/components/pipeline/PipelineBuilder.vue +13 -1
  20. package/app/components/providers/PersonalSubscriptionSection.vue +9 -0
  21. package/app/components/providers/VendorCredentialsModal.vue +12 -3
  22. package/app/components/settings/LocalModelEndpointsPanel.vue +11 -0
  23. package/app/components/settings/MergeThresholdsPanel.vue +9 -0
  24. package/app/components/settings/ModelConfigurationPanel.vue +9 -0
  25. package/app/components/settings/UserSecretsSection.vue +11 -0
  26. package/app/components/slack/SlackPanel.vue +9 -0
  27. package/app/components/tasks/ContextIssuePicker.vue +8 -4
  28. package/app/composables/useBlockDeletion.ts +63 -0
  29. package/app/composables/useConfirm.ts +63 -0
  30. package/app/composables/useKeyboardShortcuts.ts +76 -0
  31. package/app/pages/index.vue +8 -0
  32. package/app/stores/agentRuns.spec.ts +56 -0
  33. package/app/stores/agentRuns.ts +22 -4
  34. package/app/stores/ui.ts +16 -0
  35. package/app/types/domain.ts +7 -0
  36. package/i18n/locales/en.json +131 -5
  37. package/i18n/locales/es.json +135 -9
  38. package/i18n/locales/fr.json +135 -9
  39. package/i18n/locales/he.json +135 -9
  40. package/i18n/locales/ja.json +135 -9
  41. package/i18n/locales/pl.json +135 -9
  42. package/i18n/locales/tr.json +135 -9
  43. package/i18n/locales/uk.json +135 -9
  44. package/package.json +2 -2
@@ -0,0 +1,349 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type {
4
+ Block,
5
+ FrontendBackendBinding,
6
+ FrontendConfig,
7
+ FrontendEnvInjection,
8
+ FrontendPackageManager,
9
+ FrontendServeMode,
10
+ } from '~/types/domain'
11
+
12
+ // Frontend-frame (`type: 'frontend'`) configuration: how to build, serve, and mock this
13
+ // frontend for a self-contained UI test (+ an optional browsable preview on local/node),
14
+ // and its backend bindings. Each binding names an env var the frontend reads for an upstream
15
+ // URL and where that URL resolves — a bound SERVICE frame's ephemeral env (the service under
16
+ // test), or WireMock. The bindings ARE the board's frontend→service links. Persisted as a
17
+ // serialized FrontendConfig on the block via the shared updateBlock PATCH.
18
+ const props = defineProps<{ block: Block }>()
19
+
20
+ const board = useBoardStore()
21
+ const { t } = useI18n()
22
+
23
+ const config = computed<FrontendConfig>(() => props.block.frontendConfig ?? { backendBindings: [] })
24
+ const bindings = computed(() => config.value.backendBindings ?? [])
25
+
26
+ // Merge a partial onto the current config, preserving the other fields, and persist. A field
27
+ // set to undefined is dropped (JSON.stringify omits it), so the harness default applies.
28
+ function save(patch: Partial<FrontendConfig>) {
29
+ const base: FrontendConfig = props.block.frontendConfig ?? { backendBindings: [] }
30
+ board.updateBlock(props.block.id, { frontendConfig: { ...base, ...patch } })
31
+ }
32
+
33
+ // A trimmed string field: an empty value clears it (undefined) so the harness default applies.
34
+ function saveText(field: keyof FrontendConfig, value: string) {
35
+ save({ [field]: value.trim() || undefined } as Partial<FrontendConfig>)
36
+ }
37
+
38
+ // The serve port must be an integer in [1, 65535] (the contract's schema bounds). Coerce and
39
+ // clamp to a valid port, dropping anything else to undefined (clears it → the harness default),
40
+ // so an out-of-range or non-integer value never 422s the PATCH.
41
+ function saveServePort(value: string) {
42
+ const n = Math.trunc(Number(value.trim()))
43
+ save({ servePort: Number.isInteger(n) && n >= 1 && n <= 65535 ? n : undefined })
44
+ }
45
+
46
+ const PACKAGE_MANAGERS: FrontendPackageManager[] = ['pnpm', 'npm', 'yarn']
47
+ const packageManager = computed(() => config.value.packageManager ?? 'pnpm')
48
+ const serveMode = computed<FrontendServeMode>(() => config.value.serveMode ?? 'static')
49
+ const envInjection = computed<FrontendEnvInjection>(() => config.value.envInjection ?? 'build')
50
+
51
+ // The service frames on the board a binding can point at (its ephemeral env URL becomes the
52
+ // service under test). Every other binding resolves to WireMock.
53
+ const serviceFrames = computed(() => board.frames.filter((b) => b.type === 'service'))
54
+
55
+ // USelect options for a binding's source: WireMock, or one of the service frames.
56
+ const sourceItems = computed(() => [
57
+ { label: t('inspector.frontendConfig.bindings.mock'), value: 'mock' },
58
+ ...serviceFrames.value.map((f) => ({ label: f.title || f.id, value: f.id })),
59
+ ])
60
+
61
+ // The select value for a binding: 'mock', or the bound service block id.
62
+ function sourceValue(binding: FrontendBackendBinding): string {
63
+ return binding.source.kind === 'service' ? binding.source.serviceBlockId : 'mock'
64
+ }
65
+
66
+ function replaceBinding(index: number, next: FrontendBackendBinding) {
67
+ save({ backendBindings: bindings.value.map((b, i) => (i === index ? next : b)) })
68
+ }
69
+
70
+ function setBindingEnvVar(index: number, value: string) {
71
+ const b = bindings.value[index]
72
+ if (b) replaceBinding(index, { ...b, envVar: value.trim() })
73
+ }
74
+
75
+ function setBindingSource(index: number, value: string) {
76
+ const b = bindings.value[index]
77
+ if (!b) return
78
+ const source: FrontendBackendBinding['source'] =
79
+ value === 'mock' ? { kind: 'mock' } : { kind: 'service', serviceBlockId: value }
80
+ replaceBinding(index, { ...b, source })
81
+ }
82
+
83
+ function addBinding() {
84
+ save({ backendBindings: [...bindings.value, { envVar: '', source: { kind: 'mock' } }] })
85
+ }
86
+
87
+ function removeBinding(index: number) {
88
+ save({ backendBindings: bindings.value.filter((_, i) => i !== index) })
89
+ }
90
+ </script>
91
+
92
+ <template>
93
+ <div class="space-y-3">
94
+ <div class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
95
+ {{ t('inspector.frontendConfig.title') }}
96
+ </div>
97
+ <p class="text-[11px] leading-snug text-slate-500">
98
+ {{ t('inspector.frontendConfig.hint') }}
99
+ </p>
100
+
101
+ <!-- Package manager -->
102
+ <div class="space-y-1">
103
+ <span class="text-[11px] text-slate-400">{{
104
+ t('inspector.frontendConfig.packageManager')
105
+ }}</span>
106
+ <div class="flex flex-wrap gap-1">
107
+ <UButton
108
+ v-for="pm in PACKAGE_MANAGERS"
109
+ :key="pm"
110
+ :color="packageManager === pm ? 'primary' : 'neutral'"
111
+ :variant="packageManager === pm ? 'soft' : 'ghost'"
112
+ size="xs"
113
+ @click="save({ packageManager: pm })"
114
+ >
115
+ {{ pm }}
116
+ </UButton>
117
+ </div>
118
+ </div>
119
+
120
+ <!-- Build: install command + build script + output dir -->
121
+ <div class="space-y-1">
122
+ <label class="text-[11px] text-slate-400">{{
123
+ t('inspector.frontendConfig.installCommand')
124
+ }}</label>
125
+ <UInput
126
+ :model-value="config.installCommand ?? ''"
127
+ size="xs"
128
+ class="font-mono"
129
+ maxlength="400"
130
+ placeholder="pnpm install --frozen-lockfile"
131
+ @blur="(e: FocusEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)"
132
+ @keydown.enter="
133
+ (e: KeyboardEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
134
+ "
135
+ />
136
+ </div>
137
+
138
+ <div class="grid grid-cols-2 gap-2">
139
+ <div class="space-y-1">
140
+ <label class="text-[11px] text-slate-400">{{
141
+ t('inspector.frontendConfig.buildScript')
142
+ }}</label>
143
+ <UInput
144
+ :model-value="config.buildScript ?? ''"
145
+ size="xs"
146
+ class="font-mono"
147
+ maxlength="200"
148
+ placeholder="build"
149
+ @blur="(e: FocusEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)"
150
+ @keydown.enter="
151
+ (e: KeyboardEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
152
+ "
153
+ />
154
+ </div>
155
+ <div class="space-y-1">
156
+ <label class="text-[11px] text-slate-400">{{
157
+ t('inspector.frontendConfig.outputDir')
158
+ }}</label>
159
+ <UInput
160
+ :model-value="config.outputDir ?? ''"
161
+ size="xs"
162
+ class="font-mono"
163
+ maxlength="400"
164
+ placeholder="dist"
165
+ @blur="(e: FocusEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)"
166
+ @keydown.enter="
167
+ (e: KeyboardEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)
168
+ "
169
+ />
170
+ </div>
171
+ </div>
172
+
173
+ <!-- Serve: mode (static vs command) + serve script (command mode) + port -->
174
+ <div class="space-y-1">
175
+ <span class="text-[11px] text-slate-400">{{ t('inspector.frontendConfig.serveMode') }}</span>
176
+ <div class="flex flex-wrap gap-1">
177
+ <UButton
178
+ :color="serveMode === 'static' ? 'primary' : 'neutral'"
179
+ :variant="serveMode === 'static' ? 'soft' : 'ghost'"
180
+ size="xs"
181
+ @click="save({ serveMode: 'static' })"
182
+ >
183
+ {{ t('inspector.frontendConfig.serveStatic') }}
184
+ </UButton>
185
+ <UButton
186
+ :color="serveMode === 'command' ? 'primary' : 'neutral'"
187
+ :variant="serveMode === 'command' ? 'soft' : 'ghost'"
188
+ size="xs"
189
+ @click="save({ serveMode: 'command' })"
190
+ >
191
+ {{ t('inspector.frontendConfig.serveCommand') }}
192
+ </UButton>
193
+ </div>
194
+ <p class="text-[11px] leading-snug text-slate-500">
195
+ {{ t('inspector.frontendConfig.serveModeHint') }}
196
+ </p>
197
+ </div>
198
+
199
+ <div v-if="serveMode === 'command'" class="space-y-1">
200
+ <label class="text-[11px] text-slate-400">{{
201
+ t('inspector.frontendConfig.serveScript')
202
+ }}</label>
203
+ <UInput
204
+ :model-value="config.serveScript ?? ''"
205
+ size="xs"
206
+ class="font-mono"
207
+ maxlength="200"
208
+ placeholder="preview"
209
+ @blur="(e: FocusEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)"
210
+ @keydown.enter="
211
+ (e: KeyboardEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)
212
+ "
213
+ />
214
+ </div>
215
+
216
+ <div class="grid grid-cols-2 gap-2">
217
+ <div class="space-y-1">
218
+ <label class="text-[11px] text-slate-400">{{
219
+ t('inspector.frontendConfig.servePort')
220
+ }}</label>
221
+ <UInput
222
+ :model-value="config.servePort != null ? String(config.servePort) : ''"
223
+ type="number"
224
+ min="1"
225
+ max="65535"
226
+ step="1"
227
+ size="xs"
228
+ class="font-mono"
229
+ placeholder="8080"
230
+ @blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
231
+ />
232
+ </div>
233
+ <div class="space-y-1">
234
+ <label class="text-[11px] text-slate-400">{{
235
+ t('inspector.frontendConfig.mockMappingsPath')
236
+ }}</label>
237
+ <UInput
238
+ :model-value="config.mockMappingsPath ?? ''"
239
+ size="xs"
240
+ class="font-mono"
241
+ maxlength="400"
242
+ placeholder="mocks/"
243
+ @blur="
244
+ (e: FocusEvent) => saveText('mockMappingsPath', (e.target as HTMLInputElement).value)
245
+ "
246
+ @keydown.enter="
247
+ (e: KeyboardEvent) => saveText('mockMappingsPath', (e.target as HTMLInputElement).value)
248
+ "
249
+ />
250
+ </div>
251
+ </div>
252
+
253
+ <!-- Env injection: build-time env vars vs a runtime window.env shim -->
254
+ <div class="space-y-1">
255
+ <span class="text-[11px] text-slate-400">{{
256
+ t('inspector.frontendConfig.envInjection')
257
+ }}</span>
258
+ <div class="flex flex-wrap gap-1">
259
+ <UButton
260
+ :color="envInjection === 'build' ? 'primary' : 'neutral'"
261
+ :variant="envInjection === 'build' ? 'soft' : 'ghost'"
262
+ size="xs"
263
+ @click="save({ envInjection: 'build' })"
264
+ >
265
+ {{ t('inspector.frontendConfig.envBuild') }}
266
+ </UButton>
267
+ <UButton
268
+ :color="envInjection === 'runtime' ? 'primary' : 'neutral'"
269
+ :variant="envInjection === 'runtime' ? 'soft' : 'ghost'"
270
+ size="xs"
271
+ @click="save({ envInjection: 'runtime' })"
272
+ >
273
+ {{ t('inspector.frontendConfig.envRuntime') }}
274
+ </UButton>
275
+ </div>
276
+ <p class="text-[11px] leading-snug text-slate-500">
277
+ {{ t('inspector.frontendConfig.envInjectionHint') }}
278
+ </p>
279
+ </div>
280
+
281
+ <!-- Backend bindings: env var → upstream. These double as the board's frontend→service links. -->
282
+ <div class="space-y-2 border-t border-slate-800 pt-2">
283
+ <div class="flex items-center justify-between">
284
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
285
+ {{ t('inspector.frontendConfig.bindings.title') }}
286
+ </span>
287
+ <UButton
288
+ size="xs"
289
+ variant="ghost"
290
+ color="neutral"
291
+ icon="i-lucide-plus"
292
+ @click="addBinding"
293
+ />
294
+ </div>
295
+ <p class="text-[11px] leading-snug text-slate-500">
296
+ {{ t('inspector.frontendConfig.bindings.hint') }}
297
+ </p>
298
+
299
+ <div v-if="bindings.length" class="space-y-1.5">
300
+ <div v-for="(b, i) in bindings" :key="i" class="flex items-center gap-1">
301
+ <UInput
302
+ :model-value="b.envVar"
303
+ size="xs"
304
+ class="flex-1 font-mono"
305
+ maxlength="200"
306
+ placeholder="PUB_BACKEND_URL"
307
+ @blur="(e: FocusEvent) => setBindingEnvVar(i, (e.target as HTMLInputElement).value)"
308
+ @keydown.enter="
309
+ (e: KeyboardEvent) => setBindingEnvVar(i, (e.target as HTMLInputElement).value)
310
+ "
311
+ />
312
+ <USelect
313
+ :model-value="sourceValue(b)"
314
+ :items="sourceItems"
315
+ size="xs"
316
+ class="flex-1"
317
+ @update:model-value="(v: string) => setBindingSource(i, v)"
318
+ />
319
+ <UButton
320
+ size="xs"
321
+ variant="ghost"
322
+ color="neutral"
323
+ icon="i-lucide-x"
324
+ :title="t('inspector.frontendConfig.bindings.remove')"
325
+ @click="removeBinding(i)"
326
+ />
327
+ </div>
328
+ </div>
329
+ <div v-else class="text-[11px] text-slate-500">
330
+ {{ t('inspector.frontendConfig.bindings.empty') }}
331
+ </div>
332
+ </div>
333
+
334
+ <!-- Browsable preview (local/node only). -->
335
+ <div class="border-t border-slate-800 pt-2">
336
+ <UCheckbox
337
+ :model-value="config.previewEnabled === true"
338
+ :label="t('inspector.frontendConfig.previewEnabled')"
339
+ size="xs"
340
+ @update:model-value="
341
+ (v: boolean | 'indeterminate') => save({ previewEnabled: v === true ? true : undefined })
342
+ "
343
+ />
344
+ <p class="mt-1 text-[11px] leading-snug text-slate-500">
345
+ {{ t('inspector.frontendConfig.previewHint') }}
346
+ </p>
347
+ </div>
348
+ </div>
349
+ </template>
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block } from '~/types/domain'
3
+ import EmptyState from '~/components/common/EmptyState.vue'
3
4
 
4
5
  const props = defineProps<{ block: Block }>()
5
6
 
6
7
  const board = useBoardStore()
7
8
  const { depLabel } = useDepLabels()
9
+ const { confirm } = useConfirm()
8
10
  const { t } = useI18n()
9
11
 
10
12
  const deps = computed(() =>
@@ -27,8 +29,15 @@ const depMenu = computed(() => {
27
29
  }))
28
30
  })
29
31
 
30
- function removeDep(depId: string) {
31
- board.removeDependency(props.block.id, depId)
32
+ async function removeDep(dep: Block) {
33
+ const ok = await confirm({
34
+ title: t('inspector.dependencies.confirmRemove.title'),
35
+ description: t('inspector.dependencies.confirmRemove.body', { name: label(dep) }),
36
+ variant: 'destructive',
37
+ confirmLabel: t('common.remove'),
38
+ icon: 'i-lucide-unlink',
39
+ })
40
+ if (ok) board.removeDependency(props.block.id, dep.id)
32
41
  }
33
42
  </script>
34
43
 
@@ -61,13 +70,18 @@ function removeDep(depId: string) {
61
70
  ? t('inspector.dependencies.merged')
62
71
  : t('inspector.dependencies.notMerged')
63
72
  "
64
- @click="removeDep(d.id)"
73
+ @click="removeDep(d)"
65
74
  >
66
75
  {{ label(d) }}
67
76
  <UIcon name="i-lucide-x" class="ms-0.5 h-3 w-3" />
68
77
  </UBadge>
69
78
  </div>
70
- <div v-else class="text-[11px] text-slate-500">{{ t('inspector.dependencies.empty') }}</div>
79
+ <EmptyState
80
+ v-else
81
+ compact
82
+ icon="i-lucide-git-branch"
83
+ :title="t('inspector.dependencies.empty')"
84
+ />
71
85
  <div v-if="!runnable" class="mt-1 text-[10px] text-amber-400">
72
86
  {{ t('inspector.dependencies.blocked') }}
73
87
  </div>
@@ -8,6 +8,7 @@ import {
8
8
  containerPhaseLabel,
9
9
  } from '~/utils/pipelineRender'
10
10
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
11
+ import EmptyState from '~/components/common/EmptyState.vue'
11
12
 
12
13
  const props = defineProps<{ block: Block }>()
13
14
 
@@ -34,6 +35,16 @@ const reviewStageLabel = computed(() =>
34
35
  )
35
36
 
36
37
  const instance = computed(() => execution.getInstance(props.block.executionId))
38
+
39
+ // Nothing to show yet: no run, no failed run, no PR, and not awaiting a merge — render an
40
+ // empty state instead of a blank gap so the section reads as "no runs yet" rather than broken.
41
+ const isEmpty = computed(
42
+ () =>
43
+ !instance.value &&
44
+ !failedRun.value &&
45
+ !props.block.pullRequest &&
46
+ props.block.status !== 'pr_ready',
47
+ )
37
48
  // A failed run is no longer executing: a step left mid-flight must stop showing
38
49
  // its live "Spinning up…" phase (the shared failure banner renders below).
39
50
  const runFailed = computed(() => instance.value?.status === 'failed')
@@ -184,6 +195,9 @@ async function resetRun() {
184
195
  :key="i"
185
196
  class="rounded-md px-2 py-1"
186
197
  :class="i === instance.currentStep ? 'bg-slate-800/70' : ''"
198
+ data-testid="run-step"
199
+ :data-step-kind="s.agentKind"
200
+ :data-step-state="s.state"
187
201
  >
188
202
  <div class="flex items-center gap-2">
189
203
  <!-- Every agent is clickable: it opens the step-detail overlay (timing,
@@ -221,6 +235,7 @@ async function resetRun() {
221
235
  <span
222
236
  v-if="s.subtasks && s.subtasks.total > 0"
223
237
  class="ms-auto font-mono text-[10px] tabular-nums text-slate-300"
238
+ data-testid="run-subtasks"
224
239
  :title="
225
240
  s.subtasks.inProgress > 0
226
241
  ? t('inspector.execution.subtasksProgress', {
@@ -399,6 +414,15 @@ async function resetRun() {
399
414
  </p>
400
415
  </div>
401
416
 
417
+ <!-- No run yet: read as "nothing here" rather than a blank gap. -->
418
+ <EmptyState
419
+ v-if="isEmpty"
420
+ compact
421
+ icon="i-lucide-play-circle"
422
+ :title="t('inspector.execution.empty.title')"
423
+ :description="t('inspector.execution.empty.body')"
424
+ />
425
+
402
426
  <!-- PR ready: merge -->
403
427
  <UButton
404
428
  v-if="block.status === 'pr_ready'"
@@ -198,6 +198,18 @@ function edit(p: Pipeline) {
198
198
  pipelines.loadForEdit(p)
199
199
  }
200
200
 
201
+ const { confirm } = useConfirm()
202
+ async function removePipeline(p: Pipeline) {
203
+ const ok = await confirm({
204
+ title: t('pipeline.builder.confirmDeletePipeline.title'),
205
+ description: t('pipeline.builder.confirmDeletePipeline.body', { name: p.name }),
206
+ variant: 'destructive',
207
+ confirmLabel: t('common.delete'),
208
+ icon: 'i-lucide-trash-2',
209
+ })
210
+ if (ok) void pipelines.removePipeline(p.id)
211
+ }
212
+
201
213
  /** Clone any pipeline (incl. a read-only built-in) into an editable copy, then edit it. */
202
214
  async function clone(p: Pipeline) {
203
215
  try {
@@ -776,7 +788,7 @@ async function clone(p: Pipeline) {
776
788
  variant="ghost"
777
789
  size="xs"
778
790
  :title="t('pipeline.builder.delete')"
779
- @click="pipelines.removePipeline(p.id)"
791
+ @click="removePipeline(p)"
780
792
  />
781
793
  </div>
782
794
  </div>
@@ -13,6 +13,7 @@ const workspace = useWorkspaceStore()
13
13
  const models = useModelsStore()
14
14
  const toast = useToast()
15
15
  const { t, d } = useI18n()
16
+ const { confirm } = useConfirm()
16
17
 
17
18
  // Personal subscriptions are stored per-user, so they need a signed-in user. When there
18
19
  // isn't one (a deployment running without sign-in), block the form so the user doesn't
@@ -141,6 +142,14 @@ async function connect() {
141
142
  }
142
143
 
143
144
  async function disconnect(v: SubscriptionVendor) {
145
+ const ok = await confirm({
146
+ title: t('personalSubscriptions.confirmDisconnect.title'),
147
+ description: t('personalSubscriptions.confirmDisconnect.body', { vendor: vendorLabel(v) }),
148
+ variant: 'destructive',
149
+ confirmLabel: t('common.disconnect'),
150
+ icon: 'i-lucide-unplug',
151
+ })
152
+ if (!ok) return
144
153
  try {
145
154
  await personal.remove(v)
146
155
  // Removing the subscription may drop the workspace's last usable model — refresh so the
@@ -14,6 +14,7 @@ const ui = useUiStore()
14
14
  const workspace = useWorkspaceStore()
15
15
  const creds = useVendorCredentialsStore()
16
16
  const toast = useToast()
17
+ const { confirm } = useConfirm()
17
18
 
18
19
  const open = computed({
19
20
  get: () => ui.vendorCredentialsOpen,
@@ -139,9 +140,17 @@ async function add() {
139
140
  }
140
141
  }
141
142
 
142
- async function remove(id: string) {
143
+ async function remove(cred: { id: string; label: string }) {
144
+ const ok = await confirm({
145
+ title: t('providers.vendorCredentials.confirmRemove.title'),
146
+ description: t('providers.vendorCredentials.confirmRemove.body', { name: cred.label }),
147
+ variant: 'destructive',
148
+ confirmLabel: t('common.remove'),
149
+ icon: 'i-lucide-trash-2',
150
+ })
151
+ if (!ok) return
143
152
  try {
144
- await creds.remove(id)
153
+ await creds.remove(cred.id)
145
154
  } catch (e) {
146
155
  toast.add({
147
156
  title: t('providers.vendorCredentials.toast.removeFailed'),
@@ -258,7 +267,7 @@ function vendorLabel(v: SubscriptionVendor): string {
258
267
  color="error"
259
268
  variant="ghost"
260
269
  size="xs"
261
- @click="remove(c.id)"
270
+ @click="remove(c)"
262
271
  />
263
272
  </div>
264
273
  </div>
@@ -13,6 +13,7 @@ const { t } = useI18n()
13
13
  const ui = useUiStore()
14
14
  const store = useLocalModelsStore()
15
15
  const toast = useToast()
16
+ const { confirm } = useConfirm()
16
17
 
17
18
  const open = computed({
18
19
  get: () => ui.localModelsOpen,
@@ -140,6 +141,16 @@ async function save() {
140
141
  }
141
142
 
142
143
  async function remove(p: LocalRunner) {
144
+ const ok = await confirm({
145
+ title: t('settings.localModelEndpoints.confirmRemove.title'),
146
+ description: t('settings.localModelEndpoints.confirmRemove.body', {
147
+ name: LOCAL_RUNNER_LABELS[p],
148
+ }),
149
+ variant: 'destructive',
150
+ confirmLabel: t('common.remove'),
151
+ icon: 'i-lucide-trash-2',
152
+ })
153
+ if (!ok) return
143
154
  busy.value = true
144
155
  try {
145
156
  await store.remove(p)
@@ -29,6 +29,7 @@ const CONCERN_LEVELS = computed<{ value: RequirementConcernLevel; label: string
29
29
 
30
30
  const store = useMergePresetsStore()
31
31
  const toast = useToast()
32
+ const { confirm } = useConfirm()
32
33
 
33
34
  // Local editable copy per preset, kept in sync with the store. Percentages are
34
35
  // edited 0..100 and stored 0..1.
@@ -116,6 +117,14 @@ async function makeDefault(p: MergeThresholdPreset) {
116
117
  }
117
118
 
118
119
  async function remove(p: MergeThresholdPreset) {
120
+ const ok = await confirm({
121
+ title: t('settings.mergeThresholds.confirmDelete.title'),
122
+ description: t('settings.mergeThresholds.confirmDelete.body', { name: p.name }),
123
+ variant: 'destructive',
124
+ confirmLabel: t('common.delete'),
125
+ icon: 'i-lucide-trash-2',
126
+ })
127
+ if (!ok) return
119
128
  busy.value = p.id
120
129
  try {
121
130
  await store.remove(p.id)
@@ -25,6 +25,7 @@ const agents = useAgentsStore()
25
25
  const creds = useVendorCredentialsStore()
26
26
  const workspace = useWorkspaceStore()
27
27
  const toast = useToast()
28
+ const { confirm } = useConfirm()
28
29
 
29
30
  const open = computed({
30
31
  get: () => ui.modelConfigOpen,
@@ -144,6 +145,14 @@ async function setDefault(p: ModelPreset) {
144
145
  }
145
146
 
146
147
  async function remove(p: ModelPreset) {
148
+ const ok = await confirm({
149
+ title: t('settings.modelConfiguration.confirmDelete.title'),
150
+ description: t('settings.modelConfiguration.confirmDelete.body', { name: p.name }),
151
+ variant: 'destructive',
152
+ confirmLabel: t('common.delete'),
153
+ icon: 'i-lucide-trash-2',
154
+ })
155
+ if (!ok) return
147
156
  busy.value = true
148
157
  try {
149
158
  await presets.remove(p.id)
@@ -12,6 +12,7 @@ const { t } = useI18n()
12
12
  const ui = useUiStore()
13
13
  const store = useUserSecretsStore()
14
14
  const toast = useToast()
15
+ const { confirm } = useConfirm()
15
16
 
16
17
  const open = computed({
17
18
  get: () => ui.userSecretsOpen,
@@ -117,6 +118,16 @@ async function save() {
117
118
  }
118
119
 
119
120
  async function remove() {
121
+ const ok = await confirm({
122
+ title: t('settings.userSecrets.confirmRemove.title'),
123
+ description: t('settings.userSecrets.confirmRemove.body', {
124
+ name: descriptor.value?.label ?? t('settings.userSecrets.secretFallback'),
125
+ }),
126
+ variant: 'destructive',
127
+ confirmLabel: t('common.remove'),
128
+ icon: 'i-lucide-trash-2',
129
+ })
130
+ if (!ok) return
120
131
  busy.value = true
121
132
  try {
122
133
  await store.remove(kind.value)
@@ -13,6 +13,7 @@ const ui = useUiStore()
13
13
  const slack = useSlackStore()
14
14
  const toast = useToast()
15
15
  const { t } = useI18n()
16
+ const { confirm } = useConfirm()
16
17
 
17
18
  const open = computed({
18
19
  get: () => ui.slackOpen,
@@ -108,6 +109,14 @@ async function connectWithToken() {
108
109
  }
109
110
 
110
111
  async function disconnect() {
112
+ const ok = await confirm({
113
+ title: t('slack.confirmDisconnect.title'),
114
+ description: t('slack.confirmDisconnect.body'),
115
+ variant: 'destructive',
116
+ confirmLabel: t('common.disconnect'),
117
+ icon: 'i-lucide-unplug',
118
+ })
119
+ if (!ok) return
111
120
  try {
112
121
  await slack.disconnect()
113
122
  } catch (e) {