@cat-factory/app 0.96.4 → 0.97.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.
@@ -6,7 +6,7 @@ import EpicNode from './nodes/EpicNode.vue'
6
6
  import TaskDependencyEdges from './TaskDependencyEdges.vue'
7
7
  import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
8
8
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
9
- import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
9
+ import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
10
10
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
11
11
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
12
  import { useFrameStacking } from '~/composables/useFrameStacking'
@@ -102,9 +102,20 @@ function onNodeClick({ node }: NodeMouseEvent) {
102
102
  ui.select(node.id)
103
103
  }
104
104
 
105
- function onNodeDoubleClick({ node }: NodeMouseEvent) {
106
- // Frames are containers: double-click expands to reveal their tasks.
107
- ui.toggleFrame(node.id)
105
+ function onNodeDoubleClick({ event, node }: NodeMouseEvent) {
106
+ // Task cards live *inside* their frame node, so Vue Flow reports the frame here even
107
+ // when the double-click landed on a task. Resolve the real target from the DOM: a
108
+ // double-click on a task opens its focus view (the same "open this task" gesture as the
109
+ // card's review action), while a double-click on frame chrome centres the camera on the
110
+ // frame and zooms it in. Epics aren't containers, so their double-click stays a no-op.
111
+ const targetId = blockIdFromEvent(event)
112
+ const target = targetId ? board.getBlock(targetId) : undefined
113
+ if (target?.level === 'task') {
114
+ ui.select(target.id)
115
+ ui.focus(target.id)
116
+ return
117
+ }
118
+ if (node.type === 'block') void focusFrame(node.id)
108
119
  }
109
120
 
110
121
  function onPaneClick() {
@@ -147,7 +158,17 @@ async function onDrop(event: DragEvent) {
147
158
  const blockId = blockIdFromEvent(event)
148
159
  const target = blockId ? board.getBlock(blockId) : undefined
149
160
  const pipeline = pipelines.getPipeline(payload.pipelineId)
150
- if (!target || !pipeline) return
161
+ // Unknown pipeline id is an internal glitch (nothing the user can act on); a drop
162
+ // onto blank canvas / a non-block, though, needs the same "aim at a task" nudge the
163
+ // wrong-level path gives — otherwise the drop just vanishes (UX-07).
164
+ if (!pipeline) return
165
+ if (!target) {
166
+ toast.add({
167
+ title: t('board.canvas.dropOntoTaskTitle'),
168
+ description: t('board.canvas.dropOntoTaskBody'),
169
+ })
170
+ return
171
+ }
151
172
  if (target.level !== 'task') {
152
173
  toast.add({
153
174
  title: t('board.canvas.dropOntoTaskTitle'),
@@ -179,8 +200,8 @@ async function onDrop(event: DragEvent) {
179
200
  <VueFlow
180
201
  :id="BOARD_FLOW_ID"
181
202
  :nodes="nodes"
182
- :min-zoom="0.2"
183
- :max-zoom="3"
203
+ :min-zoom="BOARD_MIN_ZOOM"
204
+ :max-zoom="BOARD_MAX_ZOOM"
184
205
  :default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
185
206
  :pan-on-drag="panOnDrag"
186
207
  :elevate-nodes-on-select="false"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { useBoardFlow } from '~/composables/useBoardFlow'
2
+ import { useBoardFlow, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
3
3
  import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
4
+ import IconButton from '~/components/common/IconButton.vue'
4
5
 
5
6
  const ui = useUiStore()
6
7
  const board = useBoardStore()
@@ -10,7 +11,7 @@ const workspaceSettings = useWorkspaceSettingsStore()
10
11
  const services = useServicesStore()
11
12
  const toast = useToast()
12
13
  const { t, n } = useI18n()
13
- const { fitView, zoomIn, zoomOut } = useBoardFlow()
14
+ const { fitView, zoomIn, zoomOut, resetZoom } = useBoardFlow()
14
15
 
15
16
  async function mountService(serviceId: string, title: string) {
16
17
  try {
@@ -44,6 +45,10 @@ const mountableItems = computed(() =>
44
45
  )
45
46
 
46
47
  const zoomPct = computed(() => Math.round(ui.zoom * 100))
48
+ // Disable the zoom buttons once the camera hits a clamp (Vue Flow pins the zoom exactly
49
+ // at the limit, so a small epsilon guards against float drift). UX-16.
50
+ const atMinZoom = computed(() => ui.zoom <= BOARD_MIN_ZOOM + 0.001)
51
+ const atMaxZoom = computed(() => ui.zoom >= BOARD_MAX_ZOOM - 0.001)
47
52
  // Exhaustive (tier-2) map from level-of-detail → its label key, so adding an LOD
48
53
  // without a label fails the typecheck rather than leaking a raw key.
49
54
  const LOD_LABEL_KEYS = {
@@ -99,11 +104,13 @@ const decisionItems = computed(() =>
99
104
  class="absolute left-1/2 top-3 z-20 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
100
105
  >
101
106
  <!-- zoom controls -->
102
- <UButton
107
+ <IconButton
108
+ :label="t('board.toolbar.zoomOut')"
103
109
  icon="i-lucide-zoom-out"
104
110
  color="neutral"
105
111
  variant="ghost"
106
112
  size="sm"
113
+ :disabled="atMinZoom"
107
114
  data-testid="board-zoom-out"
108
115
  @click="
109
116
  () => {
@@ -111,16 +118,28 @@ const decisionItems = computed(() =>
111
118
  }
112
119
  "
113
120
  />
114
- <!-- The zoom %/LOD readout is the first thing to drop on narrow viewports. -->
115
- <div class="hidden w-20 text-center text-xs tabular-nums text-slate-300 sm:block">
121
+ <!-- Click the readout to snap back to 100%. Always visible (only the LOD sub-label
122
+ drops on narrow viewports) so the zoom level is never a mystery. -->
123
+ <button
124
+ type="button"
125
+ class="w-16 rounded text-center text-xs tabular-nums text-slate-300 hover:bg-slate-800 focus-visible:ring-2 focus-visible:ring-slate-400/60 sm:w-20"
126
+ :title="t('board.toolbar.resetZoom')"
127
+ :aria-label="t('board.toolbar.resetZoom')"
128
+ data-testid="board-zoom-reset"
129
+ @click="resetZoom()"
130
+ >
116
131
  {{ zoomPct }}%
117
- <div class="text-[9px] uppercase tracking-wide text-slate-500">{{ lodLabel }}</div>
118
- </div>
119
- <UButton
132
+ <span class="hidden text-[9px] uppercase tracking-wide text-slate-500 sm:block">{{
133
+ lodLabel
134
+ }}</span>
135
+ </button>
136
+ <IconButton
137
+ :label="t('board.toolbar.zoomIn')"
120
138
  icon="i-lucide-zoom-in"
121
139
  color="neutral"
122
140
  variant="ghost"
123
141
  size="sm"
142
+ :disabled="atMaxZoom"
124
143
  data-testid="board-zoom-in"
125
144
  @click="
126
145
  () => {
@@ -128,7 +147,8 @@ const decisionItems = computed(() =>
128
147
  }
129
148
  "
130
149
  />
131
- <UButton
150
+ <IconButton
151
+ :label="t('board.toolbar.fitView')"
132
152
  icon="i-lucide-maximize"
133
153
  color="neutral"
134
154
  variant="ghost"
@@ -4,8 +4,15 @@ import { useVueFlow } from '@vue-flow/core'
4
4
  * useVueFlow() from anywhere (e.g. the toolbar) accesses the same instance. */
5
5
  export const BOARD_FLOW_ID = 'board'
6
6
 
7
+ /** Camera zoom clamps — the single source of truth shared by the canvas (which passes
8
+ * them to <VueFlow>) and the toolbar (which disables its zoom buttons at the limits). */
9
+ export const BOARD_MIN_ZOOM = 0.2
10
+ export const BOARD_MAX_ZOOM = 3
11
+
7
12
  /** Camera controls for the main board, usable outside the canvas component. */
8
13
  export function useBoardFlow() {
9
- const { fitView, zoomIn, zoomOut, viewport } = useVueFlow(BOARD_FLOW_ID)
10
- return { fitView, zoomIn, zoomOut, viewport }
14
+ const { fitView, zoomIn, zoomOut, zoomTo, viewport } = useVueFlow(BOARD_FLOW_ID)
15
+ /** Snap the camera back to 100% zoom (keeps the current centre). */
16
+ const resetZoom = () => zoomTo(1, { duration: 250 })
17
+ return { fitView, zoomIn, zoomOut, zoomTo, resetZoom, viewport }
11
18
  }
@@ -48,6 +48,7 @@ const CONFLICT_TITLE_KEYS: Record<
48
48
  provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
49
49
  preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
50
50
  visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
51
+ deployer_required_before_tester: 'errors.conflict.title.deployer_required_before_tester',
51
52
  }
52
53
 
53
54
  /**
@@ -346,6 +346,18 @@ export const SYSTEM_AGENT_META: Record<string, AgentArchetype> = {
346
346
  color: '#22d3ee',
347
347
  description: 'Maps the repository into the service → modules blueprint.',
348
348
  },
349
+ // The single environment provisioner: an operational (non-LLM) step that stands up the ephemeral
350
+ // environment the tester / human-test gate run against for a kubernetes/custom service, and is a
351
+ // fast no-op for docker-compose / infraless. Seeded before the first tester/human-test step in the
352
+ // built-in pipelines, so it needs display metadata (else it renders as a generic gray "Agent").
353
+ deployer: {
354
+ kind: 'deployer',
355
+ label: 'Deployer',
356
+ icon: 'i-lucide-cloud-upload',
357
+ color: '#34d399',
358
+ description:
359
+ 'Provisions the ephemeral environment the tester and human-test gate run against (kubernetes / custom services); a no-op for docker-compose / infraless.',
360
+ },
349
361
  // The Initiative Planning pipeline's two steps. Only runnable on an initiative
350
362
  // block (pl_initiative — enforced by the engine), so they are display-metadata
351
363
  // system kinds, never palette archetypes.
package/app/utils/dnd.ts CHANGED
@@ -22,8 +22,9 @@ export function readDndPayload(event: DragEvent): DndPayload | null {
22
22
  }
23
23
  }
24
24
 
25
- /** Walk up from the drop target to find the block it landed on, if any. */
26
- export function blockIdFromEvent(event: DragEvent): string | null {
25
+ /** Walk up from an event's target to find the block it landed on, if any. Works for any
26
+ * DOM event (drop, double-click, …) only `event.target` is read. */
27
+ export function blockIdFromEvent(event: Event): string | null {
27
28
  const el = (event.target as HTMLElement | null)?.closest('[data-block-id]')
28
29
  return el?.getAttribute('data-block-id') ?? null
29
30
  }
@@ -126,7 +126,11 @@
126
126
  "spendTitle": "Token spend this month",
127
127
  "spendLimitReached": "Spend limit reached — runs paused",
128
128
  "serviceAdded": "Added {title}",
129
- "serviceAddFailed": "Could not add service"
129
+ "serviceAddFailed": "Could not add service",
130
+ "zoomOut": "Zoom out",
131
+ "zoomIn": "Zoom in",
132
+ "fitView": "Fit board to content",
133
+ "resetZoom": "Reset zoom to 100%"
130
134
  },
131
135
  "canvas": {
132
136
  "emptyTitle": "Your board is empty",
@@ -424,7 +428,8 @@
424
428
  "bootstrap_reference_missing": "Reference architecture is gone",
425
429
  "provision_type_unhandled": "No handler for this provision type",
426
430
  "preset_unsatisfiable": "Model preset can't run this pipeline",
427
- "visual_pipeline_no_frontend": "No frontend to test"
431
+ "visual_pipeline_no_frontend": "No frontend to test",
432
+ "deployer_required_before_tester": "Add a Deployer before the Tester"
428
433
  },
429
434
  "fallbackMessage": "This action conflicts with the current state.",
430
435
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "Gasto de tokens este mes",
109
109
  "spendLimitReached": "Límite de gasto alcanzado: ejecuciones en pausa",
110
110
  "serviceAdded": "Se añadió {title}",
111
- "serviceAddFailed": "No se pudo añadir el servicio"
111
+ "serviceAddFailed": "No se pudo añadir el servicio",
112
+ "zoomOut": "Alejar",
113
+ "zoomIn": "Acercar",
114
+ "fitView": "Ajustar al contenido",
115
+ "resetZoom": "Restablecer zoom al 100%"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "Tu tablero está vacío",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "La inicialización no se puede reintentar",
388
392
  "bootstrap_reference_missing": "La arquitectura de referencia ha desaparecido",
389
393
  "preset_unsatisfiable": "El preajuste de modelo no puede ejecutar esta canalización",
390
- "visual_pipeline_no_frontend": "No hay frontend que probar"
394
+ "visual_pipeline_no_frontend": "No hay frontend que probar",
395
+ "deployer_required_before_tester": "Añade un Deployer antes del Tester"
391
396
  },
392
397
  "fallbackMessage": "Esta acción entra en conflicto con el estado actual.",
393
398
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "Dépense de tokens ce mois-ci",
109
109
  "spendLimitReached": "Limite de dépense atteinte : exécutions en pause",
110
110
  "serviceAdded": "{title} ajouté",
111
- "serviceAddFailed": "Impossible d’ajouter le service"
111
+ "serviceAddFailed": "Impossible d’ajouter le service",
112
+ "zoomOut": "Dézoomer",
113
+ "zoomIn": "Zoomer",
114
+ "fitView": "Ajuster au contenu",
115
+ "resetZoom": "Réinitialiser le zoom à 100 %"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "Votre tableau est vide",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "L’initialisation ne peut pas être relancée",
388
392
  "bootstrap_reference_missing": "L’architecture de référence a disparu",
389
393
  "preset_unsatisfiable": "Le préréglage de modèle ne peut pas exécuter ce pipeline",
390
- "visual_pipeline_no_frontend": "Aucun frontend à tester"
394
+ "visual_pipeline_no_frontend": "Aucun frontend à tester",
395
+ "deployer_required_before_tester": "Ajoutez un Deployer avant le Testeur"
391
396
  },
392
397
  "fallbackMessage": "Cette action est en conflit avec l’état actuel.",
393
398
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "צריכת טוקנים החודש",
109
109
  "spendLimitReached": "הוגעה למגבלת הצריכה — הריצות הושהו",
110
110
  "serviceAdded": "{title} נוסף",
111
- "serviceAddFailed": "לא ניתן היה להוסיף את השירות"
111
+ "serviceAddFailed": "לא ניתן היה להוסיף את השירות",
112
+ "zoomOut": "הקטנה",
113
+ "zoomIn": "הגדלה",
114
+ "fitView": "התאמה לתוכן",
115
+ "resetZoom": "איפוס התקריב ל-100%"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "הלוח שלך ריק",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "לא ניתן להריץ מחדש את האתחול",
388
392
  "bootstrap_reference_missing": "ארכיטקטורת ההפניה נעלמה",
389
393
  "preset_unsatisfiable": "קדם‑הגדרת המודל אינה יכולה להריץ צנרת זו",
390
- "visual_pipeline_no_frontend": "אין frontend לבדיקה"
394
+ "visual_pipeline_no_frontend": "אין frontend לבדיקה",
395
+ "deployer_required_before_tester": "הוסף Deployer לפני ה-Tester"
391
396
  },
392
397
  "fallbackMessage": "פעולה זו מתנגשת עם המצב הנוכחי.",
393
398
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "今月のトークン消費量",
109
109
  "spendLimitReached": "消費上限に達しました。実行を一時停止しています",
110
110
  "serviceAdded": "{title} を追加しました",
111
- "serviceAddFailed": "サービスを追加できませんでした"
111
+ "serviceAddFailed": "サービスを追加できませんでした",
112
+ "zoomOut": "縮小",
113
+ "zoomIn": "拡大",
114
+ "fitView": "内容に合わせる",
115
+ "resetZoom": "ズームを100%にリセット"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "ボードが空です",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "ブートストラップは再試行できません",
388
392
  "bootstrap_reference_missing": "リファレンスアーキテクチャが見つかりません",
389
393
  "preset_unsatisfiable": "モデルプリセットではこのパイプラインを実行できません",
390
- "visual_pipeline_no_frontend": "テスト対象のフロントエンドがありません"
394
+ "visual_pipeline_no_frontend": "テスト対象のフロントエンドがありません",
395
+ "deployer_required_before_tester": "テスターの前にDeployerを追加してください"
391
396
  },
392
397
  "fallbackMessage": "この操作は現在の状態と競合します。",
393
398
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "Wydatki na tokeny w tym miesiącu",
109
109
  "spendLimitReached": "Osiągnięto limit wydatków: uruchomienia wstrzymane",
110
110
  "serviceAdded": "Dodano {title}",
111
- "serviceAddFailed": "Nie udało się dodać usługi"
111
+ "serviceAddFailed": "Nie udało się dodać usługi",
112
+ "zoomOut": "Pomniejsz",
113
+ "zoomIn": "Powiększ",
114
+ "fitView": "Dopasuj do zawartości",
115
+ "resetZoom": "Zresetuj powiększenie do 100%"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "Twoja tablica jest pusta",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "Inicjalizacji nie można ponowić",
388
392
  "bootstrap_reference_missing": "Architektura referencyjna zniknęła",
389
393
  "preset_unsatisfiable": "Ten zestaw modeli nie może uruchomić tego potoku",
390
- "visual_pipeline_no_frontend": "Brak frontendu do przetestowania"
394
+ "visual_pipeline_no_frontend": "Brak frontendu do przetestowania",
395
+ "deployer_required_before_tester": "Dodaj Deployer przed Testerem"
391
396
  },
392
397
  "fallbackMessage": "Ta akcja jest sprzeczna z bieżącym stanem.",
393
398
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "Bu ayki token harcaması",
109
109
  "spendLimitReached": "Harcama limitine ulaşıldı, çalıştırmalar duraklatıldı",
110
110
  "serviceAdded": "{title} eklendi",
111
- "serviceAddFailed": "Servis eklenemedi"
111
+ "serviceAddFailed": "Servis eklenemedi",
112
+ "zoomOut": "Uzaklaştır",
113
+ "zoomIn": "Yakınlaştır",
114
+ "fitView": "İçeriğe sığdır",
115
+ "resetZoom": "Yakınlaştırmayı %100'e sıfırla"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "Panonuz boş",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "Bootstrap yeniden denenemiyor",
388
392
  "bootstrap_reference_missing": "Referans mimari kayıp",
389
393
  "preset_unsatisfiable": "Model ön ayarı bu ardışık düzeni çalıştıramıyor",
390
- "visual_pipeline_no_frontend": "Test edilecek bir frontend yok"
394
+ "visual_pipeline_no_frontend": "Test edilecek bir frontend yok",
395
+ "deployer_required_before_tester": "Tester’dan önce bir Deployer ekleyin"
391
396
  },
392
397
  "fallbackMessage": "Bu eylem mevcut durumla çelişiyor.",
393
398
  "providersUnconfigured": {
@@ -108,7 +108,11 @@
108
108
  "spendTitle": "Витрати на токени цього місяця",
109
109
  "spendLimitReached": "Досягнуто ліміт витрат: запуски призупинено",
110
110
  "serviceAdded": "Додано {title}",
111
- "serviceAddFailed": "Не вдалося додати сервіс"
111
+ "serviceAddFailed": "Не вдалося додати сервіс",
112
+ "zoomOut": "Зменшити",
113
+ "zoomIn": "Збільшити",
114
+ "fitView": "Вписати в область",
115
+ "resetZoom": "Скинути масштаб до 100%"
112
116
  },
113
117
  "canvas": {
114
118
  "emptyTitle": "Ваша дошка порожня",
@@ -387,7 +391,8 @@
387
391
  "bootstrap_not_retryable": "Ініціалізацію неможливо повторити",
388
392
  "bootstrap_reference_missing": "Еталонна архітектура зникла",
389
393
  "preset_unsatisfiable": "Пресет моделі не може запустити цей конвеєр",
390
- "visual_pipeline_no_frontend": "Немає фронтенду для тестування"
394
+ "visual_pipeline_no_frontend": "Немає фронтенду для тестування",
395
+ "deployer_required_before_tester": "Додайте Deployer перед Tester"
391
396
  },
392
397
  "fallbackMessage": "Ця дія суперечить поточному стану.",
393
398
  "providersUnconfigured": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.96.4",
3
+ "version": "0.97.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "valibot": "^1.4.2",
35
35
  "vue": "3.5.39",
36
36
  "wretch": "^3.0.9",
37
- "@cat-factory/contracts": "0.107.0"
37
+ "@cat-factory/contracts": "0.108.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",