@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
@@ -3,7 +3,12 @@ import { computed, ref, watch } from 'vue'
3
3
  import type { AgentKind, Pipeline } from '~/types/domain'
4
4
  import AgentPalette from '~/components/palettes/AgentPalette.vue'
5
5
  import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
6
- import { agentKindMeta, companionForProducer, isConsensusEligibleKind } from '~/utils/catalog'
6
+ import {
7
+ agentKindMeta,
8
+ companionForProducer,
9
+ isConsensusEligibleKind,
10
+ isTesterKind,
11
+ } from '~/utils/catalog'
7
12
  import type { ConsensusStrategy } from '~/types/consensus'
8
13
 
9
14
  type DraftUnit = { index: number; kind: AgentKind; companionIndex: number | null }
@@ -146,15 +151,18 @@ function companionLabel(kind: string): string | null {
146
151
  }
147
152
 
148
153
  // Surfaced as an inline hint: a gated step needs a task-estimator before it (mirrors the
149
- // backend validation, which also rejects the save/start).
154
+ // backend validation, which also rejects the save/start). Both the companion estimate gate
155
+ // (`draftGating`) and the Tester QC companion's estimate gate (`draftTesterQuality[i].gating`)
156
+ // count — either without a preceding estimator is rejected on save.
150
157
  const gatingNeedsEstimator = computed(() => {
151
158
  const kinds = pipelines.draft
159
+ const hasEstimatorBefore = (i: number) =>
160
+ kinds.slice(0, i).some((k, j) => k === 'task-estimator' && pipelines.draftEnabled[j] !== false)
152
161
  for (let i = 0; i < kinds.length; i++) {
153
- if (!pipelines.draftGating[i]?.enabled || pipelines.draftEnabled[i] === false) continue
154
- const hasEstimator = kinds
155
- .slice(0, i)
156
- .some((k, j) => k === 'task-estimator' && pipelines.draftEnabled[j] !== false)
157
- if (!hasEstimator) return true
162
+ if (pipelines.draftEnabled[i] === false) continue
163
+ const gated =
164
+ pipelines.draftGating[i]?.enabled || pipelines.draftTesterQuality[i]?.gating?.enabled
165
+ if (gated && !hasEstimatorBefore(i)) return true
158
166
  }
159
167
  return false
160
168
  })
@@ -434,6 +442,30 @@ async function clone(p: Pipeline) {
434
442
  "
435
443
  @click="pipelines.toggleDraftFollowUps(unit.index)"
436
444
  />
445
+ <!-- Test quality-control companion: audits the Tester's report for coverage
446
+ before the greenlight/fixer decision and loops the Tester on gaps (Tester
447
+ steps only). Enabled by default. -->
448
+ <UButton
449
+ v-if="isTesterKind(unit.kind)"
450
+ :icon="
451
+ pipelines.draftTesterQuality[unit.index]?.enabled === false
452
+ ? 'i-lucide-shield-off'
453
+ : 'i-lucide-shield-check'
454
+ "
455
+ :color="
456
+ pipelines.draftTesterQuality[unit.index]?.enabled === false
457
+ ? 'neutral'
458
+ : 'secondary'
459
+ "
460
+ variant="ghost"
461
+ size="xs"
462
+ :title="
463
+ pipelines.draftTesterQuality[unit.index]?.enabled === false
464
+ ? t('pipeline.builder.testerQualityEnableTooltip')
465
+ : t('pipeline.builder.testerQualityDisableTooltip')
466
+ "
467
+ @click="pipelines.toggleDraftTesterQuality(unit.index)"
468
+ />
437
469
  <UButton
438
470
  icon="i-lucide-chevron-up"
439
471
  color="neutral"
@@ -642,6 +674,77 @@ async function clone(p: Pipeline) {
642
674
  </template>
643
675
  </div>
644
676
  </div>
677
+
678
+ <!-- Test quality-control companion config (shown when QC is enabled on a Tester
679
+ step): an optional estimate gate so only heavy tasks get the coverage audit. -->
680
+ <div
681
+ v-if="
682
+ isTesterKind(unit.kind) &&
683
+ pipelines.draftTesterQuality[unit.index]?.enabled !== false
684
+ "
685
+ class="ms-6 space-y-2 rounded-md border border-sky-800/40 bg-sky-950/20 p-2 text-xs"
686
+ >
687
+ <div class="flex items-center gap-1.5">
688
+ <UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5 text-sky-400" />
689
+ <span class="min-w-0 flex-1 truncate text-slate-200">
690
+ {{ t('pipeline.builder.testerQualityLabel') }}
691
+ </span>
692
+ <UButton
693
+ :icon="
694
+ pipelines.draftTesterQuality[unit.index]?.gating?.enabled
695
+ ? 'i-lucide-toggle-right'
696
+ : 'i-lucide-toggle-left'
697
+ "
698
+ :color="
699
+ pipelines.draftTesterQuality[unit.index]?.gating?.enabled
700
+ ? 'success'
701
+ : 'neutral'
702
+ "
703
+ variant="ghost"
704
+ size="xs"
705
+ :label="t('pipeline.builder.gateOnEstimate')"
706
+ :title="t('pipeline.builder.testerQualityGateTooltip')"
707
+ @click="pipelines.toggleDraftTesterQualityGating(unit.index)"
708
+ />
709
+ </div>
710
+ <div
711
+ v-if="pipelines.draftTesterQuality[unit.index]?.gating?.enabled"
712
+ class="flex flex-wrap items-center gap-2 border-t border-slate-800 pt-2"
713
+ >
714
+ <span class="text-[10px] text-slate-500">{{
715
+ t('pipeline.builder.runWhenAny')
716
+ }}</span>
717
+ <label class="text-slate-400">{{
718
+ t('pipeline.builder.complexityThreshold')
719
+ }}</label>
720
+ <input
721
+ v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minComplexity"
722
+ type="number"
723
+ min="0"
724
+ max="1"
725
+ step="0.1"
726
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
727
+ />
728
+ <label class="text-slate-400">{{ t('pipeline.builder.riskThreshold') }}</label>
729
+ <input
730
+ v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minRisk"
731
+ type="number"
732
+ min="0"
733
+ max="1"
734
+ step="0.1"
735
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
736
+ />
737
+ <label class="text-slate-400">{{ t('pipeline.builder.impactThreshold') }}</label>
738
+ <input
739
+ v-model.number="pipelines.draftTesterQuality[unit.index]!.gating!.minImpact"
740
+ type="number"
741
+ min="0"
742
+ max="1"
743
+ step="0.1"
744
+ class="w-14 rounded border border-slate-700 bg-slate-900 px-1.5 py-0.5 text-slate-100"
745
+ />
746
+ </div>
747
+ </div>
645
748
  </li>
646
749
  </ol>
647
750
  </div>
@@ -414,6 +414,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
414
414
  >
415
415
  <div
416
416
  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"
417
+ role="dialog"
418
+ aria-modal="true"
417
419
  >
418
420
  <!-- header -->
419
421
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -123,6 +123,8 @@ function kindLabel(item: RequirementItem): string {
123
123
  >
124
124
  <div
125
125
  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"
126
+ role="dialog"
127
+ aria-modal="true"
126
128
  >
127
129
  <!-- header -->
128
130
  <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
@@ -47,6 +47,12 @@ const testState = computed(() => step.value?.test ?? null)
47
47
  // ended), newest first, so the otherwise-opaque fixer sub-jobs have a surface here.
48
48
  const fixerAttempts = computed(() => [...(testState.value?.attemptLog ?? [])].reverse())
49
49
 
50
+ // Test quality-control companion state: the coverage audit the QC reviewer ran on each report
51
+ // (before the greenlight/fixer decision) plus its loop budget. Verdicts newest-first, so the
52
+ // most recent audit leads. Absent when the companion is disabled or never ran.
53
+ const quality = computed(() => step.value?.testerQuality ?? null)
54
+ const qualityVerdicts = computed(() => [...(quality.value?.verdicts ?? [])].reverse())
55
+
50
56
  // Infrastructure observability — parity with the Coder's generic step detail, so the
51
57
  // Tester window surfaces WHERE its job runs (the container lifecycle: spinning up /
52
58
  // running phase / id+url / errored), the ephemeral environment it tests against, and the
@@ -551,6 +557,91 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
551
557
  </ol>
552
558
  </section>
553
559
 
560
+ <!-- Test quality-control companion: the coverage audit(s) the QC reviewer ran on
561
+ the report before the greenlight/fixer decision. Each verdict says whether the
562
+ report adequately covered what the task needed tested, with the gaps that
563
+ looped the Tester for a focused additional pass. -->
564
+ <section
565
+ v-if="quality && qualityVerdicts.length"
566
+ data-testid="tester-quality"
567
+ class="space-y-2"
568
+ >
569
+ <div class="flex items-center gap-2">
570
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
571
+ {{ t('testing.quality.heading') }}
572
+ </h3>
573
+ <span
574
+ v-if="quality.attempts"
575
+ class="text-[11px] text-slate-400"
576
+ :title="t('testing.quality.reruns')"
577
+ >
578
+ {{
579
+ t('testing.quality.rerunCount', {
580
+ attempts: quality.attempts,
581
+ max: quality.maxAttempts,
582
+ })
583
+ }}
584
+ </span>
585
+ <UBadge
586
+ v-if="quality.exceeded"
587
+ color="warning"
588
+ variant="subtle"
589
+ size="sm"
590
+ data-testid="tester-quality-exceeded"
591
+ >
592
+ {{ t('testing.quality.exceeded') }}
593
+ </UBadge>
594
+ </div>
595
+ <ol class="space-y-2">
596
+ <li
597
+ v-for="(vd, vi) in qualityVerdicts"
598
+ :key="`qc${vi}`"
599
+ data-testid="tester-quality-verdict"
600
+ class="rounded-lg border border-slate-800 bg-slate-900/60 px-3 py-2"
601
+ >
602
+ <div class="flex items-center gap-2">
603
+ <UIcon
604
+ :name="vd.adequate ? 'i-lucide-shield-check' : 'i-lucide-shield-alert'"
605
+ class="h-3.5 w-3.5 shrink-0"
606
+ :class="vd.adequate ? 'text-emerald-400' : 'text-amber-300'"
607
+ />
608
+ <span class="text-[13px] font-medium text-slate-200">
609
+ {{
610
+ vd.adequate
611
+ ? t('testing.quality.adequate')
612
+ : t('testing.quality.inadequate')
613
+ }}
614
+ </span>
615
+ <span v-if="vd.model" class="ms-auto font-mono text-[10px] text-slate-500">{{
616
+ vd.model
617
+ }}</span>
618
+ <span class="text-[11px] text-slate-500" :class="{ 'ms-auto': !vd.model }">{{
619
+ d(new Date(vd.at), 'short')
620
+ }}</span>
621
+ </div>
622
+ <p v-if="vd.feedback" class="mt-1 text-[12px] leading-snug text-slate-400">
623
+ {{ vd.feedback }}
624
+ </p>
625
+ <div v-if="vd.gaps.length" class="mt-1.5">
626
+ <p class="text-[11px] text-slate-500">{{ t('testing.quality.gaps') }}</p>
627
+ <ul class="mt-1 space-y-0.5">
628
+ <li
629
+ v-for="(gap, gi) in vd.gaps"
630
+ :key="`qc${vi}-g${gi}`"
631
+ class="flex items-start gap-1.5 text-[12px] text-slate-300"
632
+ >
633
+ <UIcon
634
+ name="i-lucide-dot"
635
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400"
636
+ />
637
+ <span>{{ gap }}</span>
638
+ </li>
639
+ </ul>
640
+ </div>
641
+ </li>
642
+ </ol>
643
+ </section>
644
+
554
645
  <div
555
646
  v-if="!report"
556
647
  class="flex flex-col items-center justify-center gap-2 py-12 text-center text-slate-400"
@@ -71,9 +71,14 @@ export function githubApi({ send, ws }: ApiContext) {
71
71
  send(createGitHubRepoContract, { pathPrefix: ws(workspaceId), body }),
72
72
 
73
73
  // Repos the connected installation can access, annotated with whether this
74
- // workspace links each (drives the per-workspace repo picker).
75
- listGitHubAvailableRepos: (workspaceId: string) =>
76
- send(listGitHubAvailableReposContract, { pathPrefix: ws(workspaceId) }),
74
+ // workspace links each (drives the per-workspace repo picker). An optional `q`
75
+ // filters `owner/name` server-side so the add-service picker searches instead of
76
+ // prefetching the whole (possibly huge) installation; omitting it browses all.
77
+ listGitHubAvailableRepos: (workspaceId: string, q?: string) =>
78
+ send(listGitHubAvailableReposContract, {
79
+ pathPrefix: ws(workspaceId),
80
+ queryParams: { q },
81
+ }),
77
82
 
78
83
  // Set the exact set of repos this workspace links.
79
84
  setGitHubLinkedRepos: (workspaceId: string, repoGithubIds: number[]) =>
@@ -20,9 +20,17 @@ export function useKeyboardShortcuts(): void {
20
20
  const board = useBoardStore()
21
21
  const { deleteBlock } = useBlockDeletion()
22
22
 
23
- /** A modal (UModal) is on screen — let it own the keyboard; don't run global shortcuts. */
23
+ /** A modal / full-screen window is on screen — let it own the keyboard; don't run global
24
+ * shortcuts (else e.g. Delete would delete the selected block hidden BEHIND the window). The
25
+ * hand-rolled result-view + focus windows now carry `role="dialog"`, so the DOM check catches
26
+ * them; the store flags are belt-and-suspenders for the same windows. */
24
27
  function modalOpen(): boolean {
25
- return ui.commandBarOpen || !!document.querySelector('[role="dialog"]')
28
+ return (
29
+ ui.commandBarOpen ||
30
+ !!ui.resultView ||
31
+ !!ui.focusBlockId ||
32
+ !!document.querySelector('[role="dialog"]')
33
+ )
26
34
  }
27
35
 
28
36
  /** The event originates from a text field, so printable/Delete keys are edits, not shortcuts. */
@@ -3,6 +3,7 @@ import BoardCanvas from '~/components/board/BoardCanvas.vue'
3
3
  import SideBar from '~/components/layout/SideBar.vue'
4
4
  import BoardToolbar from '~/components/layout/BoardToolbar.vue'
5
5
  import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
6
+ import ConnectionStatusBanner from '~/components/layout/ConnectionStatusBanner.vue'
6
7
  import TranslationWarningBanner from '~/components/layout/TranslationWarningBanner.vue'
7
8
  import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
8
9
  import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
@@ -275,7 +276,7 @@ watch(
275
276
  class="m-auto flex flex-col items-center gap-3 text-slate-400"
276
277
  >
277
278
  <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
278
- <span class="text-sm">Loading…</span>
279
+ <span class="text-sm">{{ $t('app.loading') }}</span>
279
280
  </div>
280
281
 
281
282
  <!-- App enabled but not installed on this workspace: hard onboarding gate. -->
@@ -312,6 +313,7 @@ watch(
312
313
  />
313
314
  <BoardToolbar />
314
315
  <SpendWarningBanner />
316
+ <ConnectionStatusBanner :connected="streamConnected" />
315
317
  <InspectorPanel />
316
318
  <!-- Code-split focus view. The fade lives here (not inside the component) so the
317
319
  leave animation still plays when `focusBlockId` clears and the v-if unmounts
@@ -368,17 +370,17 @@ watch(
368
370
  <!-- Backend unreachable / bootstrap failed -->
369
371
  <div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
370
372
  <UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
371
- <h1 class="mb-1 text-lg font-semibold">Can’t reach the backend</h1>
373
+ <h1 class="mb-1 text-lg font-semibold">{{ $t('app.backendUnreachable') }}</h1>
372
374
  <p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
373
375
  <UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
374
- Retry
376
+ {{ $t('common.retry') }}
375
377
  </UButton>
376
378
  </div>
377
379
 
378
380
  <!-- Initial load -->
379
381
  <div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
380
382
  <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
381
- <span class="text-sm">Loading board…</span>
383
+ <span class="text-sm">{{ $t('app.loadingBoard') }}</span>
382
384
  </div>
383
385
  </div>
384
386
  </template>
@@ -1,6 +1,8 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { setActivePinia, createPinia } from 'pinia'
2
3
  import type { Block, BlockStatus } from '~/types/domain'
3
4
  import { useBoardStore } from '~/stores/board'
5
+ import { useWorkspaceStore } from '~/stores/workspace'
4
6
 
5
7
  /** Minimal Block factory — only the fields the read getters care about. */
6
8
  function block(id: string, over: Partial<Block> = {}): Block {
@@ -224,6 +226,22 @@ describe('board store read getters', () => {
224
226
  expect(() => store.previewMove('missing', { x: 1, y: 1 })).not.toThrow()
225
227
  })
226
228
 
229
+ it('updateBlock restores the patched fields and toasts when the write fails', async () => {
230
+ // Capture the toast the store surfaces on failure. Re-stub before creating the store so it
231
+ // binds this spy (the store resolves `useToast()` once at setup).
232
+ const addSpy = vi.fn()
233
+ vi.stubGlobal('useToast', () => ({ add: addSpy }))
234
+ setActivePinia(createPinia())
235
+ const s = useBoardStore()
236
+ s.hydrate([frame('f1', { title: 'Original', description: 'orig' })])
237
+ // With no active workspace, `requireId()` throws inside updateBlock's try — the same catch
238
+ // that a rejected API write hits — so this exercises the optimistic-rollback + toast path.
239
+ await s.updateBlock('f1', { title: 'Edited', description: 'changed' })
240
+ expect(s.getBlock('f1')?.title).toBe('Original')
241
+ expect(s.getBlock('f1')?.description).toBe('orig')
242
+ expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
243
+ })
244
+
227
245
  it('hydrate replaces and upsert inserts/updates cached blocks', () => {
228
246
  store.hydrate([frame('f1')])
229
247
  store.upsert(task('t1', 'f1', { title: 'first' }))
@@ -233,3 +251,42 @@ describe('board store read getters', () => {
233
251
  expect(store.allTasks).toHaveLength(1)
234
252
  })
235
253
  })
254
+
255
+ describe('board store optimistic rollback', () => {
256
+ // These instantiate their own store AFTER stubbing the api (the store captures
257
+ // `useApi()` at setup), unlike the read-getter suite above.
258
+ beforeEach(() => {
259
+ useWorkspaceStore().workspaceId = 'ws1'
260
+ })
261
+
262
+ it('moveBlock restores the pre-drag position when the API rejects', async () => {
263
+ vi.stubGlobal('useApi', () => ({
264
+ moveBlock: () => Promise.reject(new Error('conflict')),
265
+ }))
266
+ const store = useBoardStore()
267
+ store.hydrate([frame('f1'), task('t1', 'f1', { position: { x: 10, y: 20 } })])
268
+ await store.moveBlock('t1', { x: 500, y: 600 })
269
+ expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
270
+ })
271
+
272
+ it('moveBlock keeps the new position on success', async () => {
273
+ vi.stubGlobal('useApi', () => ({
274
+ moveBlock: async () => task('t1', 'f1', { position: { x: 500, y: 600 } }),
275
+ }))
276
+ const store = useBoardStore()
277
+ store.hydrate([frame('f1'), task('t1', 'f1', { position: { x: 10, y: 20 } })])
278
+ await store.moveBlock('t1', { x: 500, y: 600 })
279
+ expect(store.getBlock('t1')?.position).toEqual({ x: 500, y: 600 })
280
+ })
281
+
282
+ it('updateBlock restores only the patched fields when the API rejects', async () => {
283
+ vi.stubGlobal('useApi', () => ({
284
+ updateBlock: () => Promise.reject(new Error('validation')),
285
+ }))
286
+ const store = useBoardStore()
287
+ store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig', description: 'keep' })])
288
+ await store.updateBlock('t1', { title: 'renamed' })
289
+ expect(store.getBlock('t1')?.title).toBe('orig')
290
+ expect(store.getBlock('t1')?.description).toBe('keep')
291
+ })
292
+ })
@@ -29,6 +29,11 @@ interface RemovalSnapshot {
29
29
  export const useBoardStore = defineStore('board', () => {
30
30
  const api = useApi()
31
31
  const toast = useToast()
32
+ // Stores run outside a component `setup`, so resolve translations through the Nuxt app's
33
+ // global i18n instance (the same handle `plugins/locale.client.ts` uses) rather than
34
+ // `useI18n()`, which requires an active component instance.
35
+ const nuxtApp = useNuxtApp()
36
+ const tr = (key: string): string => (nuxtApp.$i18n as { t: (k: string) => string }).t(key)
32
37
  const blocks = ref<Block[]>([])
33
38
 
34
39
  // Pure derivations (hierarchy, status/progress, sizing) live in the composable.
@@ -162,7 +167,7 @@ export const useBoardStore = defineStore('board', () => {
162
167
  } catch (e) {
163
168
  t.epicId = prev
164
169
  toast.add({
165
- title: 'Could not change epic',
170
+ title: tr('board.toast.epicFailed'),
166
171
  description: e instanceof Error ? e.message : String(e),
167
172
  icon: 'i-lucide-triangle-alert',
168
173
  color: 'error',
@@ -216,7 +221,7 @@ export const useBoardStore = defineStore('board', () => {
216
221
  b.parentId = prevParentId
217
222
  b.position = prevPosition
218
223
  toast.add({
219
- title: 'Could not move',
224
+ title: tr('board.toast.moveFailed'),
220
225
  description: e instanceof Error ? e.message : String(e),
221
226
  icon: 'i-lucide-triangle-alert',
222
227
  color: 'error',
@@ -289,7 +294,7 @@ export const useBoardStore = defineStore('board', () => {
289
294
  } catch (e) {
290
295
  reattach(snap)
291
296
  toast.add({
292
- title: 'Could not delete',
297
+ title: tr('board.toast.deleteFailed'),
293
298
  description: e instanceof Error ? e.message : String(e),
294
299
  icon: 'i-lucide-triangle-alert',
295
300
  color: 'error',
@@ -313,26 +318,65 @@ export const useBoardStore = defineStore('board', () => {
313
318
  async function moveBlock(id: string, position: { x: number; y: number }) {
314
319
  const b = getBlock(id)
315
320
  if (!b) return
321
+ const prevPosition = b.position
316
322
  b.position = position // optimistic: keep the drag feeling instant
317
- // A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
318
- // on the (shared) block so route a frame drag there. Other moves write the block.
319
- const services = useServicesStore()
320
- const mount = services.serviceByFrameBlock[id]
321
- ? services.byServiceId[services.serviceByFrameBlock[id]!.id]
322
- : undefined
323
- if (mount) {
324
- await services.updateLayout(mount.serviceId, position)
325
- return
323
+ try {
324
+ // A mounted service frame's position is a PER-WORKSPACE layout override on the mount, not
325
+ // on the (shared) block — so route a frame drag there. Other moves write the block.
326
+ const services = useServicesStore()
327
+ const mount = services.serviceByFrameBlock[id]
328
+ ? services.byServiceId[services.serviceByFrameBlock[id]!.id]
329
+ : undefined
330
+ if (mount) {
331
+ await services.updateLayout(mount.serviceId, position)
332
+ return
333
+ }
334
+ upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
335
+ } catch (e) {
336
+ // Restore the pre-drag position — a rejected move must not leave the block at a
337
+ // spot the server never stored (a lie that survives until the next re-hydrate).
338
+ b.position = prevPosition
339
+ toast.add({
340
+ title: 'Could not move',
341
+ description: e instanceof Error ? e.message : String(e),
342
+ icon: 'i-lucide-triangle-alert',
343
+ color: 'error',
344
+ })
326
345
  }
327
- upsert(await api.moveBlock(useWorkspaceStore().requireId(), id, { position }))
328
346
  }
329
347
 
330
348
  /** Patch the user-editable fields of a block (title, features, threshold…). */
331
349
  async function updateBlock(id: string, patch: UpdateBlockInput) {
332
350
  const b = getBlock(id)
333
351
  if (!b) return
352
+ // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
353
+ // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
354
+ // screen with no feedback — the same rollback contract the other mutations here follow.
355
+ const prev: Record<string, unknown> = {}
356
+ const patchRecord = patch as Record<string, unknown>
357
+ const record = b as unknown as Record<string, unknown>
358
+ for (const key of Object.keys(patch)) prev[key] = record[key]
334
359
  Object.assign(b, patch) // optimistic
335
- upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
360
+ try {
361
+ upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
362
+ } catch (e) {
363
+ // Re-resolve the block: a live event may have replaced its object reference (`upsert`
364
+ // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
365
+ // fields that still hold OUR optimistic value, so a newer server value that landed
366
+ // mid-flight isn't clobbered by the rollback.
367
+ const cur = getBlock(id) as unknown as Record<string, unknown> | undefined
368
+ if (cur) {
369
+ for (const key of Object.keys(patch)) {
370
+ if (cur[key] === patchRecord[key]) cur[key] = prev[key]
371
+ }
372
+ }
373
+ toast.add({
374
+ title: tr('board.toast.updateFailed'),
375
+ description: e instanceof Error ? e.message : String(e),
376
+ icon: 'i-lucide-triangle-alert',
377
+ color: 'error',
378
+ })
379
+ }
336
380
  }
337
381
 
338
382
  /**
@@ -346,7 +390,7 @@ export const useBoardStore = defineStore('board', () => {
346
390
  upsert(await api.toggleDependency(useWorkspaceStore().requireId(), targetId, { sourceId }))
347
391
  } catch (e) {
348
392
  toast.add({
349
- title: 'Could not link tasks',
393
+ title: tr('board.toast.linkFailed'),
350
394
  description: e instanceof Error ? e.message : String(e),
351
395
  icon: 'i-lucide-triangle-alert',
352
396
  color: 'error',
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { BrainstormSession } from '~/types/brainstorm'
3
+ import { useBrainstormStore } from '~/stores/brainstorm'
4
+
5
+ /** Minimal session factory — only the fields the upsert guard touches. */
6
+ function session(over: Partial<BrainstormSession> = {}): BrainstormSession {
7
+ return {
8
+ id: 'bs1',
9
+ blockId: 'b1',
10
+ stage: 'requirements',
11
+ status: 'ready',
12
+ options: [],
13
+ updatedAt: 1000,
14
+ ...over,
15
+ } as BrainstormSession
16
+ }
17
+
18
+ describe('brainstorm store live-event upsert guard', () => {
19
+ it('an out-of-order stream event cannot revert a newer cached session', () => {
20
+ const store = useBrainstormStore()
21
+ store.upsert(session({ updatedAt: 2000, status: 'merged' }))
22
+ store.upsert(session({ updatedAt: 1000, status: 'ready' })) // stale event → ignored
23
+ expect(store.sessionFor('b1', 'requirements')?.status).toBe('merged')
24
+ store.upsert(session({ updatedAt: 3000, status: 'incorporated' }))
25
+ expect(store.sessionFor('b1', 'requirements')?.status).toBe('incorporated')
26
+ })
27
+
28
+ it('sessions are keyed per block+stage — one stage cannot clobber another', () => {
29
+ const store = useBrainstormStore()
30
+ store.upsert(session({ updatedAt: 2000 }))
31
+ store.upsert(session({ id: 'bs2', stage: 'architecture', updatedAt: 1000 }))
32
+ expect(store.sessionFor('b1', 'requirements')?.id).toBe('bs1')
33
+ expect(store.sessionFor('b1', 'architecture')?.id).toBe('bs2')
34
+ })
35
+ })
@@ -84,6 +84,16 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
84
84
  sessions.value = { ...sessions.value, [key(session.blockId, session.stage)]: session }
85
85
  }
86
86
 
87
+ /** Patch the cache from a live `brainstorm` stream event (newest wins per block+stage). */
88
+ function upsert(session: BrainstormSession) {
89
+ const existing = sessions.value[key(session.blockId, session.stage)]
90
+ // Keep the freshest by updatedAt (the consensus-store guard): `store()` also runs on
91
+ // API responses, so a slightly-older event racing a just-submitted answer over the
92
+ // separate WS transport must not revert the session the response already delivered.
93
+ if (existing && existing.id === session.id && existing.updatedAt > session.updatedAt) return
94
+ store(session)
95
+ }
96
+
87
97
  /** Drop all cached sessions + in-flight state (called on workspace switch). */
88
98
  function reset() {
89
99
  available.value = null
@@ -215,7 +225,6 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
215
225
  proceed,
216
226
  resolveExceeded,
217
227
  reset,
218
- // Patch the cache from a live `brainstorm` stream event.
219
- upsert: store,
228
+ upsert,
220
229
  }
221
230
  })
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { ClarityReview } from '~/types/clarity'
3
+ import { useClarityStore } from '~/stores/clarity'
4
+
5
+ /** Minimal review factory — only the fields the upsert guard touches. */
6
+ function review(over: Partial<ClarityReview> = {}): ClarityReview {
7
+ return {
8
+ id: 'cr1',
9
+ blockId: 'b1',
10
+ status: 'ready',
11
+ items: [],
12
+ updatedAt: 1000,
13
+ ...over,
14
+ } as ClarityReview
15
+ }
16
+
17
+ describe('clarity store live-event upsert guard', () => {
18
+ it('an out-of-order stream event cannot revert a newer cached review', () => {
19
+ const store = useClarityStore()
20
+ store.upsert(review({ updatedAt: 2000, status: 'merged' }))
21
+ store.upsert(review({ updatedAt: 1000, status: 'ready' })) // stale event → ignored
22
+ expect(store.reviewFor('b1')?.status).toBe('merged')
23
+ store.upsert(review({ updatedAt: 3000, status: 'incorporated' }))
24
+ expect(store.reviewFor('b1')?.status).toBe('incorporated')
25
+ })
26
+
27
+ it('a NEW review (different id) for the block replaces regardless of updatedAt', () => {
28
+ const store = useClarityStore()
29
+ store.upsert(review({ updatedAt: 2000 }))
30
+ store.upsert(review({ id: 'cr2', updatedAt: 1000 }))
31
+ expect(store.reviewFor('b1')?.id).toBe('cr2')
32
+ })
33
+ })