@cat-factory/app 0.30.2 → 0.30.4

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.
@@ -372,7 +372,7 @@ async function add() {
372
372
  <template>
373
373
  <UModal v-model:open="open" title="Add a task">
374
374
  <template #body>
375
- <div class="space-y-4">
375
+ <div class="space-y-4" data-testid="add-task-modal">
376
376
  <p v-if="container" class="text-xs text-slate-400">
377
377
  New task in <span class="font-medium text-slate-200">{{ container.title }}</span>
378
378
  </p>
@@ -411,6 +411,7 @@ async function add() {
411
411
  <UFormField label="Title" required>
412
412
  <UInput
413
413
  v-model="title"
414
+ data-testid="add-task-title"
414
415
  placeholder="What needs to be done?"
415
416
  autofocus
416
417
  class="w-full"
@@ -746,6 +747,7 @@ async function add() {
746
747
  <UButton color="neutral" variant="ghost" @click="ui.closeAddTask()">Cancel</UButton>
747
748
  <UButton
748
749
  color="primary"
750
+ data-testid="add-task-submit"
749
751
  :icon="isRecurring ? 'i-lucide-arrow-right' : 'i-lucide-plus'"
750
752
  :loading="saving"
751
753
  :disabled="!canAdd"
@@ -368,6 +368,7 @@ const ITEM_ICON: Record<string, string> = {
368
368
  }}</UBadge>
369
369
  <UButton
370
370
  class="nodrag"
371
+ data-testid="frame-add-task"
371
372
  size="xs"
372
373
  variant="ghost"
373
374
  color="neutral"
@@ -425,6 +426,7 @@ const ITEM_ICON: Record<string, string> = {
425
426
  <button
426
427
  v-if="!hasTasks"
427
428
  type="button"
429
+ data-testid="frame-add-task"
428
430
  class="absolute inset-4 flex items-center justify-center gap-1 rounded-lg border border-dashed border-slate-700 text-[11px] text-slate-500 hover:border-slate-500 hover:text-slate-300"
429
431
  @click.stop="addTask"
430
432
  >
@@ -4,9 +4,14 @@ import { useBlockDrag } from '~/composables/useBlockDrag'
4
4
 
5
5
  const props = defineProps<{ taskId: string }>()
6
6
  const board = useBoardStore()
7
+ const expansion = useTaskExpansionStore()
7
8
  const task = computed(() => board.getBlock(props.taskId))
8
9
  const { draggingId, startDrag } = useBlockDrag()
9
10
 
11
+ // An expanded pipeline grows downward over its neighbours, so it must stack above the
12
+ // other (compact) task cards — never let a neighbour render on top of the pipeline.
13
+ const expanded = computed(() => expansion.allowed.has(props.taskId))
14
+
10
15
  // Once a task is merged it stops being a unit of work and becomes part of the
11
16
  // architecture: it no longer renders as a draggable card (arrows fall back to its
12
17
  // container), so we never leave a zero-size anchor behind.
@@ -22,11 +27,11 @@ function onHandle(e: PointerEvent) {
22
27
  <!-- in-flight task → draggable work card (merged tasks render nothing) -->
23
28
  <div
24
29
  v-if="!merged"
25
- class="absolute w-[180px]"
30
+ class="absolute w-[210px]"
26
31
  :style="{
27
32
  left: task.position.x + 'px',
28
33
  top: task.position.y + 'px',
29
- zIndex: draggingId === taskId ? 60 : 10,
34
+ zIndex: draggingId === taskId ? 60 : expanded ? 20 : 10,
30
35
  // While this task is being dragged it must not capture hit-tests, so the
31
36
  // drop-zone (service or module) beneath the cursor can be resolved on
32
37
  // release — including the drag handle, which lives in this wrapper above
@@ -182,7 +182,9 @@ function selectTask() {
182
182
  ]"
183
183
  @click.stop="selectTask"
184
184
  >
185
- <!-- header row -->
185
+ <!-- meta row: status dot, recurring icon, status label + connect handle. The
186
+ status label is a fixed-width-ish stub ("APPROVAL NEEDED" etc.), so it sits
187
+ on its own row rather than stealing horizontal space from the title. -->
186
188
  <div class="flex items-center gap-1.5">
187
189
  <span class="h-2 w-2 shrink-0 rounded-full" :style="{ backgroundColor: statusMeta.color }" />
188
190
  <UIcon
@@ -191,19 +193,8 @@ function selectTask() {
191
193
  class="h-3 w-3 shrink-0 text-indigo-400"
192
194
  :title="schedule.enabled ? 'Recurring pipeline' : 'Recurring pipeline (paused)'"
193
195
  />
194
- <span class="truncate text-[11px] font-semibold text-slate-100">{{ task.title }}</span>
195
- <!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
196
- <button
197
- type="button"
198
- class="nodrag ml-1 shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
199
- title="Drag onto another task to make it depend on this one"
200
- @pointerdown.stop="startConnect(task.id, $event)"
201
- @click.stop
202
- >
203
- <UIcon name="i-lucide-spline" class="h-3 w-3" />
204
- </button>
205
196
  <span
206
- class="ml-auto shrink-0 text-[9px] uppercase tracking-wide"
197
+ class="ml-auto truncate text-[9px] uppercase tracking-wide"
207
198
  :class="
208
199
  runFailed
209
200
  ? 'text-rose-400'
@@ -216,6 +207,25 @@ function selectTask() {
216
207
  >
217
208
  {{ statusText }}
218
209
  </span>
210
+ <!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
211
+ <button
212
+ type="button"
213
+ class="nodrag shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
214
+ title="Drag onto another task to make it depend on this one"
215
+ @pointerdown.stop="startConnect(task.id, $event)"
216
+ @click.stop
217
+ >
218
+ <UIcon name="i-lucide-spline" class="h-3 w-3" />
219
+ </button>
220
+ </div>
221
+
222
+ <!-- title gets a full-width row so long titles wrap to two lines rather than
223
+ truncating to an unreadable stub; the full text stays available on hover. -->
224
+ <div
225
+ class="mt-1 line-clamp-2 break-words text-[11px] font-semibold leading-snug text-slate-100"
226
+ :title="task.title"
227
+ >
228
+ {{ task.title }}
219
229
  </div>
220
230
 
221
231
  <!-- a failed run: the shared failure banner + retry, never a stuck bar -->
@@ -115,7 +115,13 @@ function revealDecision(n: Notification) {
115
115
 
116
116
  <template>
117
117
  <UPopover v-if="notifications.count" :content="{ align: 'end' }">
118
- <UButton :color="hasUrgent ? 'error' : 'warning'" variant="soft" size="sm" icon="i-lucide-bell">
118
+ <UButton
119
+ data-testid="notifications-bell"
120
+ :color="hasUrgent ? 'error' : 'warning'"
121
+ variant="soft"
122
+ size="sm"
123
+ icon="i-lucide-bell"
124
+ >
119
125
  {{ notifications.count }}
120
126
  </UButton>
121
127
 
@@ -127,6 +133,8 @@ function revealDecision(n: Notification) {
127
133
  <div
128
134
  v-for="n in notifications.open"
129
135
  :key="n.id"
136
+ data-testid="notification-item"
137
+ :data-notification-type="n.type"
130
138
  class="rounded-lg border p-2.5 mt-1.5"
131
139
  :class="
132
140
  isUrgent(n)
@@ -167,6 +175,7 @@ function revealDecision(n: Notification) {
167
175
  </a>
168
176
  <div class="mt-2 flex items-center gap-1.5">
169
177
  <UButton
178
+ data-testid="notification-act"
170
179
  :color="accent(n)"
171
180
  variant="soft"
172
181
  size="xs"
@@ -176,6 +185,7 @@ function revealDecision(n: Notification) {
176
185
  {{ META[n.type].action }}
177
186
  </UButton>
178
187
  <UButton
188
+ data-testid="notification-dismiss"
179
189
  color="neutral"
180
190
  variant="ghost"
181
191
  size="xs"
@@ -175,6 +175,7 @@ async function copyOutput() {
175
175
  <Transition name="reader-fade">
176
176
  <div
177
177
  v-if="open && step && agent"
178
+ data-testid="step-detail"
178
179
  class="fixed inset-0 z-50 flex bg-slate-950/96 backdrop-blur-sm"
179
180
  role="dialog"
180
181
  aria-modal="true"
@@ -552,6 +553,7 @@ async function copyOutput() {
552
553
  <div v-else class="space-y-2 border-t border-slate-800 px-4 py-3">
553
554
  <UButton
554
555
  color="primary"
556
+ data-testid="step-approve"
555
557
  size="sm"
556
558
  icon="i-lucide-check"
557
559
  block
@@ -117,7 +117,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
117
117
  function contentSize(id: string): { w: number; h: number } {
118
118
  const b = getBlock(id)
119
119
  const isModule = b?.level === 'module'
120
- const TASK_W = 180
120
+ const TASK_W = 210
121
121
  const TASK_H = 160
122
122
  const headerH = isModule ? 30 : 0
123
123
  let w = isModule ? 200 : 360
@@ -2,8 +2,7 @@ import type { Ref } from 'vue'
2
2
  import { onMounted, onBeforeUnmount } from 'vue'
3
3
  import { useRafFn } from '@vueuse/core'
4
4
  import { lodAtLeast } from '~/composables/useSemanticZoom'
5
-
6
- type Rect = { left: number; right: number; top: number; bottom: number }
5
+ import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
7
6
 
8
7
  function intersects(a: Rect, b: Rect) {
9
8
  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
@@ -21,9 +20,13 @@ function sameSet(a: Set<string>, b: Set<string>) {
21
20
  * frame against live DOM rects so they follow pan / zoom / drag / resize:
22
21
  *
23
22
  * - visibility: a task expands only while its card overlaps the board viewport.
24
- * - overlap: walking the visible candidates nearest-to-screen-centre first, a task
25
- * expands only if its footprint doesn't collide with one already granted, so the
26
- * centre-most task wins an overlap and the rest stay compact.
23
+ * - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
24
+ * task expands only if its footprint doesn't collide with one already granted, so the
25
+ * card you're looking at wins an overlap and the rest stay compact.
26
+ * - hover: the task directly under the pointer is granted first, so hovering any card
27
+ * expands its pipeline regardless of its position on screen. "Under the pointer" is
28
+ * the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
29
+ * already covered by another open pipeline keeps that pipeline, not the card beneath.
27
30
  *
28
31
  * Writes the permitted id set into the `taskExpansion` store; `TaskPipelineMini` reads it.
29
32
  * Only tasks with a running pipeline (steps to show) are candidates — a task that
@@ -44,11 +47,30 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
44
47
  // card is still tested at its expanded extent and stays denied. Stable.
45
48
  const expandedHeight = new Map<string, number>()
46
49
 
50
+ // Last pointer position over the board (viewport coords), or null when the pointer has
51
+ // left it. The card under the pointer is expanded on hover (see `hoveredTaskId`).
52
+ let pointer: { x: number; y: number } | null = null
53
+ function onPointerMove(e: PointerEvent) {
54
+ pointer = { x: e.clientX, y: e.clientY }
55
+ }
56
+ function onPointerLeave() {
57
+ pointer = null
58
+ }
59
+
47
60
  function rectOf(id: string): DOMRect | null {
48
61
  const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
49
62
  return el ? el.getBoundingClientRect() : null
50
63
  }
51
64
 
65
+ // The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
66
+ // rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
67
+ // a region obscured by another pipeline doesn't switch to the card hidden beneath it.
68
+ function hoveredTaskId(): string | null {
69
+ if (!pointer) return null
70
+ const hit = document.elementFromPoint(pointer.x, pointer.y)
71
+ return hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
72
+ }
73
+
52
74
  function recompute() {
53
75
  // Task cards only expand at the deep zoom bands; clear everything otherwise.
54
76
  if (!lodAtLeast(ui.lod, 'steps')) {
@@ -82,28 +104,29 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
82
104
  top: rect.top,
83
105
  bottom: rect.top + height,
84
106
  }
85
- // Rank by the screen centre's distance to the card's projected footprint 0
86
- // whenever the centre sits over the card. So a tall card the viewport is parked
87
- // inside wins over a shorter neighbour whose top edge merely happens to be nearer
88
- // the centre line (the old top-centre anchor penalised a card you'd scrolled into
89
- // once its top scrolled above the viewport). The footprint is the stable expanded
90
- // extent — its top doesn't move and its bottom uses the cached expanded height —
91
- // so the ranking can't oscillate as cards expand / collapse.
92
- const ddx = Math.max(footprint.left - cx, 0, cx - footprint.right)
93
- const ddy = Math.max(footprint.top - cy, 0, cy - footprint.bottom)
94
- const dist = ddx * ddx + ddy * ddy
95
- candidates.push({ id: t.id, rect: footprint, dist })
107
+ // Rank by the screen centre's distance to the card's stable header (top edge),
108
+ // so the card you're looking at wins and a tall card's expanded body can't claim
109
+ // the centre just by covering it. See utils/taskExpansionRanking.ts.
110
+ candidates.push({ id: t.id, rect: footprint, dist: headerDistanceSq(footprint, cx, cy) })
96
111
  }
97
112
  // Drop cached heights for cards that are gone, so the map can't grow unbounded.
98
113
  for (const id of expandedHeight.keys()) if (!liveIds.has(id)) expandedHeight.delete(id)
99
114
  candidates.sort((a, b) => a.dist - b.dist)
100
115
 
101
- // Greedy by distance to centre: a candidate is granted only if its projected
102
- // footprint clears every footprint already granted, so the centre-most card
103
- // wins any overlap.
116
+ // Greedy by header distance: a candidate is granted only if its projected footprint
117
+ // clears every footprint already granted, so the centre-most card wins any overlap.
118
+ // The hovered card is granted FIRST, so hovering a card expands it regardless of its
119
+ // distance from the centre (and a centre-most neighbour it overlaps yields to it).
120
+ const hovered = hoveredTaskId()
104
121
  const claimed: Rect[] = []
105
122
  const next = new Set<string>()
123
+ const hoveredCard = hovered ? candidates.find((c) => c.id === hovered) : undefined
124
+ if (hoveredCard) {
125
+ next.add(hoveredCard.id)
126
+ claimed.push(hoveredCard.rect)
127
+ }
106
128
  for (const c of candidates) {
129
+ if (c.id === hoveredCard?.id) continue
107
130
  if (claimed.some((r) => intersects(c.rect, r))) continue
108
131
  next.add(c.id)
109
132
  claimed.push(c.rect)
@@ -114,10 +137,16 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
114
137
  const { pause, resume } = useRafFn(recompute, { immediate: false })
115
138
  onMounted(() => {
116
139
  store.setDriverActive(true)
140
+ const el = container.value
141
+ el?.addEventListener('pointermove', onPointerMove)
142
+ el?.addEventListener('pointerleave', onPointerLeave)
117
143
  resume()
118
144
  })
119
145
  onBeforeUnmount(() => {
120
146
  pause()
147
+ const el = container.value
148
+ el?.removeEventListener('pointermove', onPointerMove)
149
+ el?.removeEventListener('pointerleave', onPointerLeave)
121
150
  store.setDriverActive(false)
122
151
  })
123
152
  }
@@ -173,7 +173,7 @@ describe('board store read getters', () => {
173
173
  ])
174
174
  // module inner width/height fit the task, plus the 30px module header.
175
175
  const size = store.containerSize('m1')
176
- expect(size.w).toBe(300 + 180 + 12)
176
+ expect(size.w).toBe(300 + 210 + 12)
177
177
  expect(size.h).toBe(200 + 160 + 12 + 30)
178
178
  })
179
179
 
@@ -7,8 +7,9 @@ import { ref } from 'vue'
7
7
  * Deep-zoom (`steps`/`subtasks`) grows a task card downward, and cards are
8
8
  * absolutely positioned in their frame, so several expanded cards stacked
9
9
  * vertically pile on top of each other. The board driver (`useTaskExpansion`)
10
- * recomputes a permitted set every frame — only on-screen cards, and only the
11
- * one closest to the screen centre when two would overlap and writes it here.
10
+ * recomputes a permitted set every frame — only on-screen cards, the one closest to
11
+ * the screen centre when two would overlap, plus whichever card the pointer is hovering
12
+ * (hover expands a card regardless of position) — and writes it here.
12
13
  * `TaskPipelineMini` reads `canExpand` to decide whether to expand or stay compact.
13
14
  *
14
15
  * `driverActive` lets the gate degrade gracefully: with no board driver mounted
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { headerDistanceSq, type Rect } from './taskExpansionRanking'
3
+
4
+ /** Rank a set of cards against a screen centre, best (would-expand) first. */
5
+ function rank(cards: Record<string, Rect>, cx: number, cy: number): string[] {
6
+ return Object.entries(cards)
7
+ .map(([id, rect]) => ({ id, dist: headerDistanceSq(rect, cx, cy) }))
8
+ .sort((a, b) => a.dist - b.dist)
9
+ .map((c) => c.id)
10
+ }
11
+
12
+ describe('headerDistanceSq', () => {
13
+ it('measures from the centre of the card top edge', () => {
14
+ const r: Rect = { left: 0, right: 100, top: 200, bottom: 600 }
15
+ // top-centre is (50, 200); centre (50, 240) → dy 40 → 40² = 1600
16
+ expect(headerDistanceSq(r, 50, 240)).toBe(1600)
17
+ // 30px to the right of the top-centre, level with the top → 30² = 900
18
+ expect(headerDistanceSq(r, 80, 200)).toBe(900)
19
+ })
20
+
21
+ it('ignores the expanded height — only the top edge counts', () => {
22
+ const short: Rect = { left: 0, right: 100, top: 200, bottom: 260 }
23
+ const tall: Rect = { left: 0, right: 100, top: 200, bottom: 2000 }
24
+ expect(headerDistanceSq(short, 50, 240)).toBe(headerDistanceSq(tall, 50, 240))
25
+ })
26
+ })
27
+
28
+ describe('ranking', () => {
29
+ // The regression from the screenshot: a tall card parked at the top of the screen
30
+ // expands its pipeline down past the centre, so its body covers the centre. A compact
31
+ // card whose header sits right at the centre must still win — the one you're looking at.
32
+ it('prefers the card whose header is at the centre over a tall card bleeding down from the top', () => {
33
+ const top: Rect = { left: 0, right: 200, top: 30, bottom: 700 } // header far up, body covers centre
34
+ const here: Rect = { left: 0, right: 200, top: 320, bottom: 520 } // header at the centre
35
+ expect(rank({ top, here }, 100, 340)).toEqual(['here', 'top'])
36
+ // document order can't flip the winner
37
+ expect(rank({ here, top }, 100, 340)).toEqual(['here', 'top'])
38
+ })
39
+
40
+ it('ranks by the header nearest the centre regardless of expansion state', () => {
41
+ const above: Rect = { left: 0, right: 200, top: 100, bottom: 800 }
42
+ const below: Rect = { left: 0, right: 200, top: 360, bottom: 420 }
43
+ expect(rank({ above, below }, 100, 320)).toEqual(['below', 'above'])
44
+ })
45
+
46
+ it('uses horizontal offset to break a vertical tie', () => {
47
+ const near: Rect = { left: 0, right: 100, top: 100, bottom: 200 }
48
+ const far: Rect = { left: 400, right: 500, top: 100, bottom: 200 }
49
+ expect(rank({ far, near }, 80, 100)).toEqual(['near', 'far'])
50
+ })
51
+ })
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Pure ranking for the task-expansion gate (see `composables/useTaskExpansion.ts`).
3
+ *
4
+ * A task card grows DOWNWARD when it expands its pipeline, so its `top`/`left`/`right`
5
+ * never move — only its height does. We rank candidates by the screen centre's distance
6
+ * to each card's stable HEADER anchor (the top edge), which is what the user reads as
7
+ * "the card I'm looking at". Ranking on the header (not the expanded footprint) is what
8
+ * stops a tall card from winning just because its expanded body happens to cover the
9
+ * centre: a compact card whose header sits right at the centre beats a neighbour whose
10
+ * header is parked at the top of the screen, even when both bodies overlap the centre.
11
+ *
12
+ * Because the anchor uses only top/left/right (all stable as the card expands and
13
+ * collapses), the ordering can't oscillate. A tall card you've scrolled past the top of
14
+ * is no longer kept expanded by the ranking itself — the hover "pin" in the driver keeps
15
+ * the pipeline you're pointing at from collapsing while you scroll.
16
+ */
17
+ export type Rect = { left: number; right: number; top: number; bottom: number }
18
+
19
+ /**
20
+ * Squared distance from the screen centre `(cx, cy)` to a card's stable header anchor
21
+ * (the centre of its top edge). Smaller = nearer the centre = ranks first.
22
+ */
23
+ export function headerDistanceSq(card: Rect, cx: number, cy: number): number {
24
+ const ax = (card.left + card.right) / 2
25
+ const dx = ax - cx
26
+ const dy = card.top - cy
27
+ return dx * dx + dy * dy
28
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.30.2",
3
+ "version": "0.30.4",
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",