@cat-factory/app 0.72.0 → 0.74.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.
Files changed (46) hide show
  1. package/app/components/board/nodes/TaskCard.vue +12 -2
  2. package/app/components/bootstrap/BootstrapModal.vue +23 -7
  3. package/app/components/brainstorm/BrainstormWindow.vue +2 -0
  4. package/app/components/clarity/ClarityReviewWindow.vue +2 -0
  5. package/app/components/consensus/ConsensusSessionWindow.vue +2 -0
  6. package/app/components/focus/BlockFocusView.vue +2 -0
  7. package/app/components/followUp/FollowUpWindow.vue +2 -0
  8. package/app/components/gates/GateResultView.vue +2 -0
  9. package/app/components/github/AddServiceFromRepoModal.vue +54 -85
  10. package/app/components/humanTest/HumanTestWindow.vue +2 -0
  11. package/app/components/layout/ConnectionStatusBanner.vue +81 -0
  12. package/app/components/layout/NotificationsInbox.vue +15 -0
  13. package/app/components/panels/GenericStructuredResultView.vue +2 -0
  14. package/app/components/panels/InspectorPanel.vue +30 -1
  15. package/app/components/panels/inspector/TaskExecution.vue +25 -1
  16. package/app/components/pipeline/PipelineBuilder.vue +110 -7
  17. package/app/components/requirements/RequirementsReviewWindow.vue +2 -0
  18. package/app/components/spec/ServiceSpecWindow.vue +2 -0
  19. package/app/components/testing/TestReportWindow.vue +91 -0
  20. package/app/composables/api/github.ts +8 -3
  21. package/app/composables/useKeyboardShortcuts.ts +10 -2
  22. package/app/pages/index.vue +6 -4
  23. package/app/stores/board.spec.ts +58 -1
  24. package/app/stores/board.ts +59 -15
  25. package/app/stores/brainstorm.spec.ts +35 -0
  26. package/app/stores/brainstorm.ts +11 -2
  27. package/app/stores/clarity.spec.ts +33 -0
  28. package/app/stores/clarity.ts +11 -2
  29. package/app/stores/execution.spec.ts +71 -13
  30. package/app/stores/execution.ts +44 -6
  31. package/app/stores/github.ts +12 -3
  32. package/app/stores/pipelines.ts +53 -0
  33. package/app/stores/recurringPipelines.ts +5 -1
  34. package/app/stores/requirements.spec.ts +21 -0
  35. package/app/stores/requirements.ts +11 -2
  36. package/app/stores/workspace.ts +1 -1
  37. package/app/utils/catalog.ts +9 -0
  38. package/i18n/locales/en.json +56 -5
  39. package/i18n/locales/es.json +56 -5
  40. package/i18n/locales/fr.json +56 -5
  41. package/i18n/locales/he.json +56 -5
  42. package/i18n/locales/ja.json +56 -5
  43. package/i18n/locales/pl.json +56 -5
  44. package/i18n/locales/tr.json +56 -5
  45. package/i18n/locales/uk.json +56 -5
  46. package/package.json +2 -2
@@ -14,6 +14,7 @@ const agentRuns = useAgentRunsStore()
14
14
  const reviews = useReviewStage()
15
15
  const toast = useToast()
16
16
  const { t } = useI18n()
17
+ const { confirm } = useConfirm()
17
18
 
18
19
  const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
19
20
  const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
@@ -106,8 +107,17 @@ function review() {
106
107
  ui.focus(props.taskId)
107
108
  }
108
109
 
109
- function merge() {
110
- execution.mergePr(props.taskId)
110
+ async function merge() {
111
+ // Merging a PR into its base is consequential and effectively irreversible — gate it behind a
112
+ // confirm (plain, not the destructive shape). `execution.mergePr` surfaces its own error toast.
113
+ const ok = await confirm({
114
+ title: t('board.task.mergeConfirm.title'),
115
+ description: t('board.task.mergeConfirm.body'),
116
+ confirmLabel: t('board.task.mergeConfirm.confirm'),
117
+ icon: 'i-lucide-git-merge',
118
+ })
119
+ if (!ok) return
120
+ await execution.mergePr(props.taskId)
111
121
  }
112
122
 
113
123
  // A `blocked` task is waiting on a human for one of two reasons — an agent-raised
@@ -444,7 +444,11 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
444
444
  >
445
445
  <div class="space-y-2">
446
446
  <div class="flex items-center gap-2">
447
- <UInput v-model="repoName" placeholder="payments-service" class="w-full" />
447
+ <UInput
448
+ v-model="repoName"
449
+ :placeholder="t('bootstrap.targetRepo.namePlaceholder')"
450
+ class="w-full"
451
+ />
448
452
  <UButton
449
453
  color="neutral"
450
454
  variant="subtle"
@@ -493,7 +497,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
493
497
  >
494
498
  <UInput
495
499
  v-model="description"
496
- placeholder="Handles payment intents and refunds"
500
+ :placeholder="t('bootstrap.description.placeholder')"
497
501
  class="w-full"
498
502
  />
499
503
  </UFormField>
@@ -640,7 +644,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
640
644
  <USelect
641
645
  v-model="archRepoSlug"
642
646
  :items="repoOptions"
643
- placeholder="owner/name"
647
+ :placeholder="t('bootstrap.arch.pickRepo.placeholder')"
644
648
  class="w-full"
645
649
  />
646
650
  </UFormField>
@@ -650,14 +654,26 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
650
654
  :description="t('bootstrap.arch.name.description')"
651
655
  required
652
656
  >
653
- <UInput v-model="archForm.name" placeholder="Service Template" class="w-full" />
657
+ <UInput
658
+ v-model="archForm.name"
659
+ :placeholder="t('bootstrap.arch.name.placeholder')"
660
+ class="w-full"
661
+ />
654
662
  </UFormField>
655
663
  <div class="grid grid-cols-2 gap-2">
656
664
  <UFormField :label="t('bootstrap.arch.repoOwner')" required>
657
- <UInput v-model="archForm.repoOwner" placeholder="acme" class="w-full" />
665
+ <UInput
666
+ v-model="archForm.repoOwner"
667
+ :placeholder="t('bootstrap.arch.repoOwnerPlaceholder')"
668
+ class="w-full"
669
+ />
658
670
  </UFormField>
659
671
  <UFormField :label="t('bootstrap.arch.repoName')" required>
660
- <UInput v-model="archForm.repoName" placeholder="service-template" class="w-full" />
672
+ <UInput
673
+ v-model="archForm.repoName"
674
+ :placeholder="t('bootstrap.arch.repoNamePlaceholder')"
675
+ class="w-full"
676
+ />
661
677
  </UFormField>
662
678
  </div>
663
679
  <UFormField :label="t('bootstrap.description.label')">
@@ -674,7 +690,7 @@ const statusLabel = computed<Record<BootstrapStatus, string>>(() => ({
674
690
  <UTextarea
675
691
  v-model="archForm.defaultInstructions"
676
692
  :rows="2"
677
- placeholder="e.g. keep the structure; rename packages to match the new service"
693
+ :placeholder="t('bootstrap.arch.defaultInstructions.placeholder')"
678
694
  class="w-full"
679
695
  />
680
696
  </UFormField>
@@ -256,6 +256,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
256
256
  >
257
257
  <div
258
258
  class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
259
+ role="dialog"
260
+ aria-modal="true"
259
261
  >
260
262
  <!-- header -->
261
263
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -246,6 +246,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
246
246
  >
247
247
  <div
248
248
  class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
249
+ role="dialog"
250
+ aria-modal="true"
249
251
  >
250
252
  <!-- header -->
251
253
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -98,6 +98,8 @@ function topScore(c: ConsensusContribution): { label: string; value: number } |
98
98
  >
99
99
  <div
100
100
  class="flex max-h-[90dvh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
101
+ role="dialog"
102
+ aria-modal="true"
101
103
  >
102
104
  <!-- header -->
103
105
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -62,6 +62,8 @@ function openApprovalFor(approvalId: string) {
62
62
  <div
63
63
  v-if="block && statusMeta && typeMeta"
64
64
  class="absolute inset-0 z-30 flex flex-col bg-slate-950/95 backdrop-blur"
65
+ role="dialog"
66
+ aria-modal="true"
65
67
  >
66
68
  <!-- header / breadcrumb -->
67
69
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -90,6 +90,8 @@ const STATUS_META: Record<
90
90
  >
91
91
  <div
92
92
  class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
93
+ role="dialog"
94
+ aria-modal="true"
93
95
  >
94
96
  <!-- Header -->
95
97
  <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
@@ -170,6 +170,8 @@ const conflictVerdict = computed(() => {
170
170
  >
171
171
  <div
172
172
  class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
173
+ role="dialog"
174
+ aria-modal="true"
173
175
  >
174
176
  <!-- Header -->
175
177
  <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
@@ -4,14 +4,14 @@
4
4
  // it in a container), this just links a repo the App can access to a fresh,
5
5
  // `ready` service frame. The workspace need not track the repo yet: the backend
6
6
  // links + syncs it on import. If the App can't see the wanted repo, the user
7
- // grants it access from here, then refreshes the list.
7
+ // grants it access from here, then searches for it again.
8
8
  //
9
9
  // MONOREPO support: a repo flagged a monorepo can back SEVERAL services, each
10
10
  // pinned to a subdirectory. When the selected repo is a monorepo, the user
11
11
  // browses its tree and picks the service's directory before adding (and may add
12
12
  // more than one, a subset of the repo's services).
13
13
  import { refDebounced } from '@vueuse/core'
14
- import type { FrameRepoType } from '~/types/domain'
14
+ import type { FrameRepoType, GitHubAvailableRepo } from '~/types/domain'
15
15
  import GitHubConnect from '~/components/github/GitHubConnect.vue'
16
16
  import RepoTreeBrowser from '~/components/github/RepoTreeBrowser.vue'
17
17
  import ServiceTestConfig from '~/components/panels/inspector/ServiceTestConfig.vue'
@@ -42,7 +42,10 @@ const adding = ref(false)
42
42
  async function loadRepos() {
43
43
  try {
44
44
  await github.probe()
45
- if (github.connected) await Promise.all([github.load(), github.loadAvailableRepos()])
45
+ // Load only the linked-repo projection (to flag repos already on the board). The
46
+ // available-repos list is NOT prefetched — it's searched server-side on demand once
47
+ // the user types (see the repoQueryRaw watcher), so a wide install isn't shipped whole.
48
+ if (github.connected) await github.load()
46
49
  } catch {
47
50
  // Integration off / unreachable → the picker stays empty, GitHubConnect shows.
48
51
  }
@@ -66,76 +69,57 @@ const onBoardIds = computed(
66
69
  () => new Set(github.repos.filter((r) => r.blockId).map((r) => r.githubId)),
67
70
  )
68
71
 
69
- const repoItems = computed(() =>
70
- github.availableRepos.map((r) => {
71
- const onBoard = onBoardIds.value.has(r.githubId) && !r.isMonorepo
72
- const suffix = [
73
- r.private ? t('github.addService.repoLabel.private') : '',
74
- r.isMonorepo ? t('github.addService.repoLabel.monorepo') : '',
75
- onBoard ? t('github.addService.repoLabel.onBoard') : '',
76
- ].join('')
77
- return {
78
- label: `${r.owner}/${r.name}${suffix}`,
79
- // Searched on (lowercased once) — the owner/name, so the filter matches either.
80
- search: `${r.owner}/${r.name}`.toLowerCase(),
81
- value: r.githubId,
82
- disabled: onBoard,
83
- }
84
- }),
85
- )
72
+ // Map an available repo to a combobox item. The label carries the private/monorepo/
73
+ // on-board suffixes; an on-board whole-repo is disabled (it already backs a service).
74
+ function toRepoItem(r: GitHubAvailableRepo) {
75
+ const onBoard = onBoardIds.value.has(r.githubId) && !r.isMonorepo
76
+ const suffix = [
77
+ r.private ? t('github.addService.repoLabel.private') : '',
78
+ r.isMonorepo ? t('github.addService.repoLabel.monorepo') : '',
79
+ onBoard ? t('github.addService.repoLabel.onBoard') : '',
80
+ ].join('')
81
+ return { label: `${r.owner}/${r.name}${suffix}`, value: r.githubId, disabled: onBoard }
82
+ }
83
+
84
+ const repoItems = computed(() => github.availableRepos.map(toRepoItem))
86
85
 
87
- // The PAT (or a wide App install) can expose hundreds of repos, far too many for a plain
88
- // dropdown — so the picker is a typeahead combobox. The user types and matching repos
89
- // surface: matching is a debounced, case-insensitive substring over `owner/name` (so any
90
- // part of either matches). For a LARGE list the search only kicks in once at least
91
- // MIN_SEARCH_LEN characters are typed, to keep early keystrokes from listing hundreds of
92
- // rows; but when the whole list is small enough to browse (<= BROWSE_ALL_MAX) the gate is
93
- // dropped and every repo is offered up-front (typing then narrows), so a handful of repos
94
- // stays pickable without having to type — matching the old always-open dropdown.
86
+ // A wide App install (or a PAT) can expose hundreds of repos, far too many to prefetch and
87
+ // filter in the browser — so the picker searches SERVER-SIDE. Nothing is fetched on open;
88
+ // once the user types at least MIN_SEARCH_LEN characters the (debounced) query is sent to
89
+ // the backend, which returns only the `owner/name` matches. Below the gate the list stays
90
+ // empty and the field prompts for more characters.
95
91
  const MIN_SEARCH_LEN = 3
96
- const BROWSE_ALL_MAX = 25
97
92
  const repoSearch = ref('')
98
93
  const repoSearchDebounced = refDebounced(repoSearch, 250)
99
- // Trimmed (original case) for display; lowercased for matching.
94
+ // Trimmed for display + the min-length gate; the backend matches case-insensitively.
100
95
  const repoQueryRaw = computed(() => repoSearchDebounced.value.trim())
101
- const repoQuery = computed(() => repoQueryRaw.value.toLowerCase())
102
-
103
- // Small lists are browseable without typing; large lists require the min-length gate.
104
- const browseAll = computed(() => repoItems.value.length <= BROWSE_ALL_MAX)
105
- // True only on a large list whose query is still too short to search.
106
- const belowMinChars = computed(() => !browseAll.value && repoQuery.value.length < MIN_SEARCH_LEN)
107
-
108
- // Matches for the current query. On a small list an empty query lists everything; on a
109
- // large list nothing surfaces until the query passes the min length.
110
- const queryMatches = computed(() => {
111
- if (belowMinChars.value) return []
112
- if (!repoQuery.value) return repoItems.value
113
- return repoItems.value.filter((r) => r.search.includes(repoQuery.value))
96
+
97
+ // True while the query is too short to search — the picker shows the "type N chars" hint.
98
+ const belowMinChars = computed(() => repoQueryRaw.value.length < MIN_SEARCH_LEN)
99
+
100
+ // The selected repo, captured when picked (below). The loaded list is volatile a later
101
+ // search replaces it so the selection can't be derived from `availableRepos` after the fact.
102
+ const selectedRepo = ref<GitHubAvailableRepo | undefined>(undefined)
103
+
104
+ // Fetch matches server-side as the debounced query changes; below the gate clear the list
105
+ // so stale matches don't linger (the hint shows instead). Granting the App access needs no
106
+ // manual refresh — the next search hits GitHub live, with no cached list to invalidate.
107
+ watch(repoQueryRaw, (q) => {
108
+ void github.loadAvailableRepos(q.length >= MIN_SEARCH_LEN ? q : '')
114
109
  })
115
110
 
116
- // Items fed to the combobox: the query matches plus the current selection kept present,
117
- // so the menu can still render the selected repo's label once the (reset) search term no
118
- // longer matches it.
111
+ // The server already filtered, so the matches ARE the loaded repos (empty below the gate).
112
+ const queryMatches = computed(() => (belowMinChars.value ? [] : repoItems.value))
113
+
114
+ // Items fed to the combobox: the matches plus the current selection kept present, so the
115
+ // menu still renders the selected repo's label after a later search replaces the list.
119
116
  const repoMenuItems = computed(() => {
120
117
  const matches = queryMatches.value
121
118
  if (selectedRepoId.value === undefined) return matches
122
119
  if (matches.some((r) => r.value === selectedRepoId.value)) return matches
123
- const selected = repoItems.value.find((r) => r.value === selectedRepoId.value)
124
- return selected ? [selected, ...matches] : matches
120
+ return selectedRepo.value ? [toRepoItem(selectedRepo.value), ...matches] : matches
125
121
  })
126
122
 
127
- // The count summary under the field is shown only when it's meaningful: there are matches
128
- // to count AND the user is actually searching (or browsing a small list). After a
129
- // selection resets the search term this is false, so the field doesn't claim "Showing 0"
130
- // or nag "type 3 characters" right under the repo the user just picked. The zero-match and
131
- // min-length messages are owned by the combobox's own empty state instead.
132
- const showResultCount = computed(() => !belowMinChars.value && queryMatches.value.length > 0)
133
-
134
- const hasRepos = computed(() => github.availableRepos.length > 0)
135
- const selectedRepo = computed(() =>
136
- github.availableRepos.find((r) => r.githubId === selectedRepoId.value),
137
- )
138
-
139
123
  // ---- monorepo flag + directory picker ------------------------------------
140
124
 
141
125
  // The monorepo flag is MODAL-LOCAL state, sent as part of the add-service request
@@ -151,8 +135,14 @@ function toggleMonorepo(value: boolean) {
151
135
  selectedDirectory.value = undefined
152
136
  }
153
137
 
154
- // On repo change, seed the toggle from the repo's persisted flag and clear the rest.
155
- watch(selectedRepoId, () => {
138
+ // On repo change, capture the picked repo (from the volatile loaded list, before a later
139
+ // search replaces it), seed the monorepo toggle from its persisted flag, and clear the rest.
140
+ watch(selectedRepoId, (id) => {
141
+ if (id === undefined) selectedRepo.value = undefined
142
+ else {
143
+ const found = github.availableRepos.find((r) => r.githubId === id)
144
+ if (found) selectedRepo.value = found
145
+ }
156
146
  isMonorepo.value = selectedRepo.value?.isMonorepo === true
157
147
  selectedDirectory.value = undefined
158
148
  configuredBlockId.value = undefined
@@ -286,10 +276,7 @@ function done() {
286
276
  :description="t('github.addService.repositoryHint')"
287
277
  required
288
278
  >
289
- <div v-if="!hasRepos" class="text-sm text-slate-400">
290
- {{ t('github.addService.noReposAvailable') }}
291
- </div>
292
- <div v-else class="space-y-1.5">
279
+ <div class="space-y-1.5">
293
280
  <UInputMenu
294
281
  v-model="selectedRepoId"
295
282
  v-model:search-term="repoSearch"
@@ -317,19 +304,11 @@ function done() {
317
304
  t('github.addService.searchMinChars', { min: MIN_SEARCH_LEN }, MIN_SEARCH_LEN)
318
305
  }}
319
306
  </span>
320
- <span v-else>{{
307
+ <span v-else-if="!github.loadingAvailable">{{
321
308
  t('github.addService.noMatches', { query: repoQueryRaw })
322
309
  }}</span>
323
310
  </template>
324
311
  </UInputMenu>
325
- <p v-if="showResultCount" class="text-xs text-slate-500">
326
- {{
327
- t('github.addService.showingCount', {
328
- shown: queryMatches.length,
329
- total: repoItems.length,
330
- })
331
- }}
332
- </p>
333
312
  </div>
334
313
  </UFormField>
335
314
 
@@ -402,16 +381,6 @@ function done() {
402
381
  >
403
382
  {{ t('github.addService.grantAccess') }}
404
383
  </UButton>
405
- <UButton
406
- color="neutral"
407
- variant="ghost"
408
- size="sm"
409
- icon="i-lucide-refresh-cw"
410
- :loading="github.loadingAvailable"
411
- @click="github.loadAvailableRepos()"
412
- >
413
- {{ t('github.addService.refreshList') }}
414
- </UButton>
415
384
  </div>
416
385
 
417
386
  <div class="flex justify-end gap-2">
@@ -142,6 +142,8 @@ const canDestroy = computed(
142
142
  >
143
143
  <div
144
144
  class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
145
+ role="dialog"
146
+ aria-modal="true"
145
147
  >
146
148
  <!-- Header -->
147
149
  <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import { computed, onBeforeUnmount, ref, watch } from 'vue'
3
+
4
+ // A slim top strip shown when the real-time WebSocket has dropped and is reconnecting, so a
5
+ // silently-frozen board (events stop arriving, nothing updates) is no longer indistinguishable
6
+ // from an idle one. `useWorkspaceStream` already reconnects with exponential backoff and
7
+ // resyncs on reconnect — this only makes that state visible. The `connected` ref is passed in
8
+ // as a prop (the page owns the single stream instance; creating another here would open a
9
+ // second socket).
10
+ const props = defineProps<{ connected: boolean }>()
11
+
12
+ const { t } = useI18n()
13
+
14
+ // Only surface a RE-connection, never the initial connect: once we've been connected we know a
15
+ // later drop is a real interruption worth flagging. A short debounce rides out a quick socket
16
+ // flap so a momentary blip doesn't flash the strip.
17
+ const everConnected = ref(false)
18
+ const showAfterDelay = ref(false)
19
+ let timer: ReturnType<typeof setTimeout> | null = null
20
+
21
+ watch(
22
+ () => props.connected,
23
+ (connected) => {
24
+ if (connected) {
25
+ everConnected.value = true
26
+ showAfterDelay.value = false
27
+ if (timer) {
28
+ clearTimeout(timer)
29
+ timer = null
30
+ }
31
+ return
32
+ }
33
+ if (!everConnected.value || timer) return
34
+ timer = setTimeout(() => {
35
+ showAfterDelay.value = true
36
+ timer = null
37
+ }, 1500)
38
+ },
39
+ { immediate: true },
40
+ )
41
+
42
+ const visible = computed(() => everConnected.value && !props.connected && showAfterDelay.value)
43
+
44
+ // Don't leave a pending debounce timer firing into a torn-down component.
45
+ onBeforeUnmount(() => {
46
+ if (timer) {
47
+ clearTimeout(timer)
48
+ timer = null
49
+ }
50
+ })
51
+ </script>
52
+
53
+ <template>
54
+ <Transition name="fade">
55
+ <div
56
+ v-if="visible"
57
+ class="pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-2"
58
+ >
59
+ <div
60
+ class="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-500/60 bg-amber-950/90 px-3 py-1.5 text-xs text-amber-100 shadow-lg backdrop-blur"
61
+ role="status"
62
+ aria-live="polite"
63
+ data-testid="stream-reconnecting"
64
+ >
65
+ <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
66
+ <span>{{ t('app.reconnecting') }}</span>
67
+ </div>
68
+ </div>
69
+ </Transition>
70
+ </template>
71
+
72
+ <style scoped>
73
+ .fade-enter-active,
74
+ .fade-leave-active {
75
+ transition: opacity 0.2s ease;
76
+ }
77
+ .fade-enter-from,
78
+ .fade-leave-to {
79
+ opacity: 0;
80
+ }
81
+ </style>
@@ -16,6 +16,17 @@ const toast = useToast()
16
16
 
17
17
  const busy = ref<string | null>(null)
18
18
 
19
+ /** Toast a failed act/dismiss — the store throws, so without this a failure was silent and the
20
+ * item just stayed in the inbox with no explanation. */
21
+ function notifyError(title: string, e: unknown) {
22
+ toast.add({
23
+ title,
24
+ description: e instanceof Error ? e.message : String(e),
25
+ icon: 'i-lucide-triangle-alert',
26
+ color: 'error',
27
+ })
28
+ }
29
+
19
30
  /** Per-type display metadata (icon, colour). The primary-action label is resolved
20
31
  * separately through the i18n catalog (`ACTION_KEYS`). */
21
32
  type Accent = 'warning' | 'primary' | 'error'
@@ -98,6 +109,8 @@ async function act(n: Notification) {
98
109
  color: 'success',
99
110
  icon: 'i-lucide-check',
100
111
  })
112
+ } catch (e) {
113
+ notifyError(t('layout.notifications.toast.actFailed'), e)
101
114
  } finally {
102
115
  busy.value = null
103
116
  }
@@ -112,6 +125,8 @@ async function dismiss(n: Notification) {
112
125
  color: 'neutral',
113
126
  icon: 'i-lucide-check',
114
127
  })
128
+ } catch (e) {
129
+ notifyError(t('layout.notifications.toast.dismissFailed'), e)
115
130
  } finally {
116
131
  busy.value = null
117
132
  }
@@ -60,6 +60,8 @@ const customJson = computed<string | null>(() => {
60
60
  >
61
61
  <div
62
62
  class="m-4 flex w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
63
+ role="dialog"
64
+ aria-modal="true"
63
65
  >
64
66
  <!-- Header -->
65
67
  <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
@@ -54,6 +54,21 @@ const block = computed<Block | undefined>(() =>
54
54
  )
55
55
  const level = computed(() => block.value?.level ?? 'frame')
56
56
  const isFrame = computed(() => level.value === 'frame')
57
+
58
+ // The title to fall back to when the user clears the field and blurs. Editing the title binds
59
+ // straight to the store object via v-model, so there is nothing to fall back to otherwise —
60
+ // restore this rather than persisting (and showing) an empty title. It is captured fresh at
61
+ // edit start (`captureTitle` on focus), so it always reflects the current known-good value —
62
+ // robust against a failed save (which rolls the title back) and an external rename of the same
63
+ // block. Seeded here on block switch so the fallback is sane even before the first focus.
64
+ const lastSavedTitle = ref('')
65
+ watch(
66
+ () => block.value?.id,
67
+ () => {
68
+ lastSavedTitle.value = block.value?.title ?? ''
69
+ },
70
+ { immediate: true },
71
+ )
57
72
  const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
58
73
  const isTask = computed(() => level.value === 'task')
59
74
  const isEpic = computed(() => level.value === 'epic')
@@ -103,11 +118,24 @@ const started = computed(
103
118
  )
104
119
  const editable = computed(() => !started.value)
105
120
 
121
+ // Snapshot the current title as the fallback the moment editing begins, so it reflects the
122
+ // last known-good value (survives a failed save or an external rename) rather than a value we
123
+ // only optimistically assumed had persisted.
124
+ function captureTitle() {
125
+ lastSavedTitle.value = block.value?.title ?? ''
126
+ }
106
127
  function saveTitle() {
107
128
  const b = block.value
108
129
  if (!b) return
109
130
  const next = b.title.trim()
110
- if (next) board.updateBlock(b.id, { title: next })
131
+ // An emptied title can't persist — restore the last saved value so the field never shows a
132
+ // blank the user didn't intend (and the board keeps a real label).
133
+ if (!next) {
134
+ b.title = lastSavedTitle.value
135
+ return
136
+ }
137
+ b.title = next
138
+ board.updateBlock(b.id, { title: next })
111
139
  }
112
140
  function saveDescription() {
113
141
  const b = block.value
@@ -253,6 +281,7 @@ const showOriginalDescription = ref(false)
253
281
  size="sm"
254
282
  class="w-full"
255
283
  :placeholder="t('panels.inspector.titlePlaceholder')"
284
+ @focus="captureTitle"
256
285
  @change="saveTitle"
257
286
  @blur="saveTitle"
258
287
  />
@@ -18,6 +18,7 @@ const ui = useUiStore()
18
18
  const models = useModelsStore()
19
19
  const reviews = useReviewStage()
20
20
  const { t, te } = useI18n()
21
+ const { confirm } = useConfirm()
21
22
 
22
23
  // The async stage this task's iterative reviewer gate (requirements-review / clarity-review)
23
24
  // is mid-cycle in (folding the answers, then re-reviewing), or null. While set, the gate is
@@ -146,6 +147,16 @@ async function stopRun() {
146
147
  const resetting = ref(false)
147
148
  async function resetRun() {
148
149
  if (resetting.value) return
150
+ // Destructive: discards the run and returns the task to planned — gate it behind a confirm,
151
+ // matching the confirm-then-mutate contract the board delete path uses.
152
+ const ok = await confirm({
153
+ title: t('inspector.execution.resetConfirm.title'),
154
+ description: t('inspector.execution.resetConfirm.body'),
155
+ variant: 'destructive',
156
+ confirmLabel: t('inspector.execution.resetConfirm.confirm'),
157
+ icon: 'i-lucide-trash-2',
158
+ })
159
+ if (!ok) return
149
160
  resetting.value = true
150
161
  try {
151
162
  await execution.cancel(props.block.id)
@@ -153,6 +164,19 @@ async function resetRun() {
153
164
  resetting.value = false
154
165
  }
155
166
  }
167
+
168
+ // Merging a PR is consequential and effectively irreversible — confirm first. `execution.mergePr`
169
+ // surfaces its own error toast, so no catch is needed here.
170
+ async function mergePr() {
171
+ const ok = await confirm({
172
+ title: t('inspector.execution.mergeConfirm.title'),
173
+ description: t('inspector.execution.mergeConfirm.body'),
174
+ confirmLabel: t('inspector.execution.mergeConfirm.confirm'),
175
+ icon: 'i-lucide-git-merge',
176
+ })
177
+ if (!ok) return
178
+ await execution.mergePr(props.block.id)
179
+ }
156
180
  </script>
157
181
 
158
182
  <template>
@@ -436,7 +460,7 @@ async function resetRun() {
436
460
  size="sm"
437
461
  icon="i-lucide-git-merge"
438
462
  block
439
- @click="execution.mergePr(block.id)"
463
+ @click="mergePr"
440
464
  >
441
465
  {{ t('inspector.execution.mergePr') }}
442
466
  </UButton>