@cat-factory/app 0.30.4 → 0.30.6

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.
@@ -11,7 +11,8 @@ import { STATUS_META } from '~/utils/catalog'
11
11
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
12
12
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
13
13
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
14
- import { computeDisplacement } from '~/utils/boardDisplacement'
14
+ import { useBlockDrag } from '~/composables/useBlockDrag'
15
+ import { useFrameStacking } from '~/composables/useFrameStacking'
15
16
 
16
17
  const board = useBoardStore()
17
18
  const pipelines = usePipelinesStore()
@@ -21,10 +22,12 @@ const github = useGitHubStore()
21
22
  const toast = useToast()
22
23
 
23
24
  const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
25
+ const { draggingId } = useBlockDrag()
26
+ const { hoveredFrameId } = useFrameStacking()
24
27
 
25
28
  // Gate which task cards expand their pipeline list on deep zoom: on-screen, and the
26
29
  // centre-most of any that would overlap (see useTaskExpansion). Service frames have no
27
- // such gate — they are always expanded to their task canvas (see frameOffsets below).
30
+ // such gate — they are always expanded to their task canvas.
28
31
  const boardEl = ref<HTMLElement | null>(null)
29
32
  useTaskExpansion(boardEl)
30
33
 
@@ -36,83 +39,46 @@ useTaskExpansion(boardEl)
36
39
  // and fill much of the viewport, so leaving them draggable would turn the whole canvas
37
40
  // into a dead zone. We therefore make every frame non-draggable (the pane pans straight
38
41
  // through it) and move it via its header handle instead.
39
-
40
- // Services are always expanded to their full task canvas. An expanded card grows
41
- // rightward / downward from its stored (chip-sized) top-left and would overlap its
42
- // neighbours, so compressed space pushes the neighbours away by that growth: the
43
- // footprint never overlaps a neighbour it wasn't already overlapping. Because the
44
- // expanded set never changes, the layout is fixed — panning never shifts it and there
45
- // is no expand/collapse transition to snap on. Render-only; stored positions untouched.
46
- const FRAME_COLLAPSED_W = 224 // the stored chip footprint (`w-56`) the layout reserves
47
- const FRAME_COLLAPSED_H = 150
48
- const FRAME_CHROME_W = 40 // border + padding around the inner task canvas
49
- const FRAME_CHROME_H = 120 // top bar + header row + paddings above the canvas
50
-
51
- const frameOffsets = computed(() => {
52
- const boxes = [
53
- ...board.frames.map((b) => {
54
- const c = board.containerSize(b.id)
55
- return {
56
- id: b.id,
57
- x: b.position.x,
58
- y: b.position.y,
59
- w: FRAME_COLLAPSED_W,
60
- h: FRAME_COLLAPSED_H,
61
- growX: Math.max(0, c.w + FRAME_CHROME_W - FRAME_COLLAPSED_W),
62
- growY: Math.max(0, c.h + FRAME_CHROME_H - FRAME_COLLAPSED_H),
63
- }
64
- }),
65
- // Epics never expand, but they're pushed aside like any other box so an expanded
66
- // frame doesn't end up rendered on top of one.
67
- ...board.epics.map((b) => ({
68
- id: b.id,
69
- x: b.position.x,
70
- y: b.position.y,
71
- w: FRAME_COLLAPSED_W,
72
- h: FRAME_COLLAPSED_H,
73
- growX: 0,
74
- growY: 0,
75
- })),
76
- ]
77
- return computeDisplacement(boxes)
78
- })
79
-
80
- function offsetOf(id: string) {
81
- return frameOffsets.value.get(id) ?? { dx: 0, dy: 0 }
42
+ //
43
+ // Frames are rendered exactly at their stored position and may overlap freely
44
+ // moving one never shifts another. The frame being dragged is lifted to the top,
45
+ // then the hovered frame (the un-obscured one under the pointer), so overlapping
46
+ // services can always be reached and reordered. See useFrameStacking.
47
+ //
48
+ // `elevate-nodes-on-select` is turned OFF on <VueFlow> for this to work: Vue Flow's
49
+ // default adds +1000 to a selected node's z-index, so a frame stayed pinned on top
50
+ // after a click and no amount of hovering another frame could surface it. Stacking
51
+ // is driven purely by hover/drag here; the selection highlight is the ring, not z.
52
+ function frameZIndex(id: string) {
53
+ if (draggingId.value === id) return 1000
54
+ if (hoveredFrameId.value === id) return 100
55
+ return 1
82
56
  }
83
57
 
84
58
  const nodes = computed(() => [
85
- ...board.frames.map((b) => {
86
- const o = offsetOf(b.id)
87
- return {
88
- id: b.id,
89
- type: 'block',
90
- position: { x: b.position.x + o.dx, y: b.position.y + o.dy },
91
- // Always-expanded frames fill the viewport; keep them non-draggable so the pane
92
- // pans through them (they move via their header handle, see BlockNode).
93
- draggable: false,
94
- data: {},
95
- }
96
- }),
59
+ ...board.frames.map((b) => ({
60
+ id: b.id,
61
+ type: 'block',
62
+ position: { x: b.position.x, y: b.position.y },
63
+ // Always-expanded frames fill the viewport; keep them non-draggable so the pane
64
+ // pans through them (they move via their header handle, see BlockNode).
65
+ draggable: false,
66
+ zIndex: frameZIndex(b.id),
67
+ data: {},
68
+ })),
97
69
  // Epics are top-level grouping nodes (non-structural), drawn alongside frames and
98
70
  // linked to their member tasks by the dependency-edge overlay.
99
- ...board.epics.map((b) => {
100
- const o = offsetOf(b.id)
101
- return {
102
- id: b.id,
103
- type: 'epic',
104
- position: { x: b.position.x + o.dx, y: b.position.y + o.dy },
105
- draggable: true,
106
- data: {},
107
- }
108
- }),
71
+ ...board.epics.map((b) => ({
72
+ id: b.id,
73
+ type: 'epic',
74
+ position: { x: b.position.x, y: b.position.y },
75
+ draggable: true,
76
+ data: {},
77
+ })),
109
78
  ])
110
79
 
111
80
  onNodeDragStop(({ node }) => {
112
- // node.position carries the render offset (compressed space can have pushed this node
113
- // aside); subtract it so we persist the un-displaced position.
114
- const o = offsetOf(node.id)
115
- board.moveBlock(node.id, { x: node.position.x - o.dx, y: node.position.y - o.dy })
81
+ board.moveBlock(node.id, node.position)
116
82
  })
117
83
 
118
84
  onViewportChange((vp) => {
@@ -202,6 +168,7 @@ async function onDrop(event: DragEvent) {
202
168
  :max-zoom="3"
203
169
  :default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
204
170
  :pan-on-drag="[0, 2]"
171
+ :elevate-nodes-on-select="false"
205
172
  fit-view-on-init
206
173
  @node-click="onNodeClick"
207
174
  @node-double-click="onNodeDoubleClick"
@@ -8,6 +8,7 @@ import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
8
8
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
9
9
  import { useBlockDrag } from '~/composables/useBlockDrag'
10
10
  import { useFrameResize } from '~/composables/useFrameResize'
11
+ import { useFrameStacking } from '~/composables/useFrameStacking'
11
12
 
12
13
  // Vue Flow passes the node's `id` and `data` as props to custom node components.
13
14
  // Only frames are rendered as board nodes; their tasks live inside the card.
@@ -91,13 +92,20 @@ function toggleExpand() {
91
92
  }
92
93
 
93
94
  // Expanded frames are not Vue Flow-draggable (so the pane can pan through them),
94
- // so they're repositioned by grabbing the header handle instead. Frames live in
95
- // free-floating flow space, hence `clamp: false`.
95
+ // so they're repositioned by grabbing the header instead. The whole header bar is
96
+ // the grab surface only the action buttons (marked `.nodrag`) opt out, so a press
97
+ // on them clicks rather than starts a drag. Frames live in free-floating flow space,
98
+ // hence `clamp: false`.
96
99
  const { startDrag } = useBlockDrag()
97
100
  function onFrameHandle(e: PointerEvent) {
101
+ if ((e.target as HTMLElement).closest('.nodrag')) return
98
102
  if (block.value) startDrag(block.value, e, { clamp: false })
99
103
  }
100
104
 
105
+ // Lift this frame above any overlapping neighbours while the pointer is over it
106
+ // (see useFrameStacking + BoardCanvas's frameZIndex).
107
+ const { enter: enterFrame, leave: leaveFrame } = useFrameStacking()
108
+
101
109
  // Miro-style frame resizing: drag the right / bottom edges or the corner. Handles
102
110
  // live on the expanded card's drop zone (see template); the composable clamps to
103
111
  // the frame's content extent and persists the size on release.
@@ -158,7 +166,13 @@ const ITEM_ICON: Record<string, string> = {
158
166
  </script>
159
167
 
160
168
  <template>
161
- <div v-if="block" class="relative" :data-block-id="block.id">
169
+ <div
170
+ v-if="block"
171
+ class="relative"
172
+ :data-block-id="block.id"
173
+ @pointerenter="enterFrame(block.id)"
174
+ @pointerleave="leaveFrame(block.id)"
175
+ >
162
176
  <!-- decision / approval indicator floats above the card at all zoom levels -->
163
177
  <div
164
178
  v-if="blockDecisions.length || blockApprovals.length"
@@ -341,16 +355,17 @@ const ITEM_ICON: Record<string, string> = {
341
355
  <AgentFailureCard :run="run" variant="expanded" />
342
356
  </div>
343
357
  <div class="space-y-3 p-4">
344
- <!-- frame header (doubles as the drag handle for the expanded frame) -->
345
- <div class="flex items-start justify-between gap-2">
346
- <!-- `nopan` stops Vue Flow's pane from panning on a left-drag that starts on
347
- this handle (it pans via d3-zoom's mousedown, which our pointerdown
348
- stopPropagation can't intercept), so the grab drives the frame move. -->
349
- <div
350
- class="nopan flex cursor-grab items-center gap-2 active:cursor-grabbing"
351
- title="Drag service"
352
- @pointerdown="onFrameHandle"
353
- >
358
+ <!-- frame header: the whole bar is the drag handle for the expanded frame.
359
+ `nopan` stops Vue Flow's pane from panning on a left-drag that starts here
360
+ (it pans via d3-zoom's mousedown, which our pointerdown stopPropagation
361
+ can't intercept), so the grab drives the frame move. The action buttons
362
+ on the right opt out via `.nodrag` (onFrameHandle ignores them). -->
363
+ <div
364
+ class="nopan flex cursor-grab items-start justify-between gap-2 active:cursor-grabbing"
365
+ title="Drag service"
366
+ @pointerdown="onFrameHandle"
367
+ >
368
+ <div class="flex items-center gap-2">
354
369
  <div
355
370
  class="flex h-8 w-8 items-center justify-center rounded-lg"
356
371
  :style="{ backgroundColor: typeMeta!.accent + '22' }"
@@ -1,17 +1,23 @@
1
1
  import { ref } from 'vue'
2
2
  import type { Block } from '~/types/domain'
3
3
 
4
+ // Only one block is ever dragged at a time, so the dragged id is a module-level
5
+ // singleton: the component that starts the drag and a sibling that needs to react
6
+ // to it (e.g. BoardCanvas elevating the dragged service frame's z-index) read the
7
+ // same ref instead of separate per-call copies.
8
+ const draggingId = ref<string | null>(null)
9
+
4
10
  /**
5
11
  * Pointer-driven dragging for blocks positioned inside a container's 2D canvas
6
- * (tasks inside services/modules, modules inside services). Movement is divided
7
- * by the board zoom so the block tracks the cursor. When `reparent` is set, the
8
- * drop point is hit-tested against `[data-drop-zone]` ancestors so a task can be
9
- * dragged from a service into a module (or back out).
12
+ * (tasks inside services/modules, modules inside services) and for free-floating
13
+ * service frames (via their header handle). Movement is divided by the board zoom
14
+ * so the block tracks the cursor. When `reparent` is set, the drop point is
15
+ * hit-tested against `[data-drop-zone]` ancestors so a task can be dragged from a
16
+ * service into a module (or back out).
10
17
  */
11
18
  export function useBlockDrag() {
12
19
  const board = useBoardStore()
13
20
  const ui = useUiStore()
14
- const draggingId = ref<string | null>(null)
15
21
 
16
22
  function startDrag(
17
23
  block: Block,
@@ -0,0 +1,18 @@
1
+ import { ref } from 'vue'
2
+
3
+ // Service frames can overlap freely on the board. The frame the pointer is over
4
+ // is, by definition, the un-obscured one at that point (pointerenter fires on the
5
+ // topmost element), so we track it and lift it above every overlapping neighbour.
6
+ // Module-level singleton: BlockNode sets it on hover, BoardCanvas reads it to set
7
+ // the Vue Flow node's z-index.
8
+ const hoveredFrameId = ref<string | null>(null)
9
+
10
+ export function useFrameStacking() {
11
+ function enter(id: string) {
12
+ hoveredFrameId.value = id
13
+ }
14
+ function leave(id: string) {
15
+ if (hoveredFrameId.value === id) hoveredFrameId.value = null
16
+ }
17
+ return { hoveredFrameId, enter, leave }
18
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.30.4",
3
+ "version": "0.30.6",
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",
@@ -1,63 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { computeDisplacement, type DisplacementBox } from '~/utils/boardDisplacement'
3
-
4
- function box(p: Partial<DisplacementBox> & { id: string }): DisplacementBox {
5
- return { x: 0, y: 0, w: 10, h: 10, growX: 0, growY: 0, ...p }
6
- }
7
-
8
- describe('computeDisplacement', () => {
9
- it('pushes a right-hand neighbour by the expanded box growth, preserving the gap', () => {
10
- // A at x=0 w=10 grows by 90; B sits 20 to the right (x=30) in the same row.
11
- const offsets = computeDisplacement([
12
- box({ id: 'A', x: 0, w: 10, growX: 90 }),
13
- box({ id: 'B', x: 30 }),
14
- ])
15
- expect(offsets.get('A')).toEqual({ dx: 0, dy: 0 })
16
- // B moves right by 90, so the 20px gap (A's new right edge 100 → B's new left 120) is kept.
17
- expect(offsets.get('B')).toEqual({ dx: 90, dy: 0 })
18
- })
19
-
20
- it('does not push a neighbour that was already overlapping the box horizontally', () => {
21
- // B starts inside A's collapsed extent (x=5 < A.right=10): already overlapping, left as-is.
22
- const offsets = computeDisplacement([
23
- box({ id: 'A', x: 0, w: 10, growX: 90 }),
24
- box({ id: 'B', x: 5 }),
25
- ])
26
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
27
- })
28
-
29
- it('accumulates the growth of every expanded box to the left (chains)', () => {
30
- const offsets = computeDisplacement([
31
- box({ id: 'A', x: 0, w: 10, growX: 90 }),
32
- box({ id: 'B', x: 30, w: 10, growX: 50 }),
33
- box({ id: 'C', x: 60 }),
34
- ])
35
- // C is right of both A and B → pushed by 90 + 50.
36
- expect(offsets.get('C')!.dx).toBe(140)
37
- // B is right of A only → pushed by 90.
38
- expect(offsets.get('B')!.dx).toBe(90)
39
- })
40
-
41
- it('does not cross-push boxes that are disjoint on the other axis', () => {
42
- // A grows rightward but B is far below it (no shared rows) → no horizontal push.
43
- const offsets = computeDisplacement([
44
- box({ id: 'A', x: 0, y: 0, w: 10, h: 10, growX: 90 }),
45
- box({ id: 'B', x: 30, y: 500, w: 10, h: 10 }),
46
- ])
47
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
48
- })
49
-
50
- it('pushes a box below an expanded box downward', () => {
51
- const offsets = computeDisplacement([
52
- box({ id: 'A', x: 0, y: 0, w: 10, h: 10, growY: 100 }),
53
- box({ id: 'B', x: 0, y: 30, w: 10, h: 10 }),
54
- ])
55
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 100 })
56
- })
57
-
58
- it('returns a zero offset for every box when nothing is expanded', () => {
59
- const offsets = computeDisplacement([box({ id: 'A', x: 0 }), box({ id: 'B', x: 30 })])
60
- expect(offsets.get('A')).toEqual({ dx: 0, dy: 0 })
61
- expect(offsets.get('B')).toEqual({ dx: 0, dy: 0 })
62
- })
63
- })
@@ -1,62 +0,0 @@
1
- /**
2
- * Compressed-space layout for the board. When a box (a service frame, or a task
3
- * card inside a frame) expands, it grows rightward / downward from its stored
4
- * top-left. Rather than letting the expanded footprint overlap its neighbours —
5
- * and then collapsing one of them to resolve the clash, which is what made a
6
- * zoomed-in service "snap out" as you scrolled across it — we PUSH the neighbours
7
- * away by the growth, so an expanded box never overlaps a neighbour it wasn't
8
- * already overlapping. The box stays expanded; you just scroll a bit further to
9
- * reach the next one.
10
- *
11
- * The result is a render-only offset per box; stored positions are never mutated.
12
- */
13
- export type DisplacementBox = {
14
- id: string
15
- /** Stored top-left, in flow units. */
16
- x: number
17
- y: number
18
- /** Collapsed size, in flow units. */
19
- w: number
20
- h: number
21
- /** Extra size when expanded (0 for a collapsed box). */
22
- growX: number
23
- growY: number
24
- }
25
-
26
- export type Offset = { dx: number; dy: number }
27
-
28
- /** Do the two boxes' collapsed extents overlap on the y-axis? */
29
- function overlapsY(a: DisplacementBox, b: DisplacementBox) {
30
- return a.y < b.y + b.h && b.y < a.y + a.h
31
- }
32
-
33
- /** Do the two boxes' collapsed extents overlap on the x-axis? */
34
- function overlapsX(a: DisplacementBox, b: DisplacementBox) {
35
- return a.x < b.x + b.w && b.x < a.x + a.w
36
- }
37
-
38
- /**
39
- * The render offset for every box. A box B is pushed right by the horizontal
40
- * growth of each expanded box E that sits to B's left (B starts past E's collapsed
41
- * right edge, so it isn't already overlapping E on x) and shares B's rows (their
42
- * collapsed y-extents overlap, so E growing rightward would actually reach B).
43
- * Symmetric for the downward push. Offsets only ever grow, so the function can
44
- * never create a new overlap, preserves left-to-right / top-to-bottom order and
45
- * the gaps between boxes, and chained expansions accumulate (the sum is taken from
46
- * the stable stored positions, so it doesn't oscillate). O(N x expanded).
47
- */
48
- export function computeDisplacement(boxes: DisplacementBox[]): Map<string, Offset> {
49
- const expanded = boxes.filter((e) => e.growX > 0 || e.growY > 0)
50
- const out = new Map<string, Offset>()
51
- for (const b of boxes) {
52
- let dx = 0
53
- let dy = 0
54
- for (const e of expanded) {
55
- if (e.id === b.id) continue
56
- if (e.growX > 0 && b.x >= e.x + e.w && overlapsY(e, b)) dx += e.growX
57
- if (e.growY > 0 && b.y >= e.y + e.h && overlapsX(e, b)) dy += e.growY
58
- }
59
- out.set(b.id, { dx, dy })
60
- }
61
- return out
62
- }