@cat-factory/app 0.30.4 → 0.30.5
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.
- package/app/components/board/BoardCanvas.vue +32 -71
- package/app/components/board/nodes/BlockNode.vue +28 -13
- package/app/composables/useBlockDrag.ts +11 -5
- package/app/composables/useFrameStacking.ts +18 -0
- package/package.json +1 -1
- package/app/utils/boardDisplacement.spec.ts +0 -63
- package/app/utils/boardDisplacement.ts +0 -62
|
@@ -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 {
|
|
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
|
|
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,41 @@ 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
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
+
function frameZIndex(id: string) {
|
|
48
|
+
if (draggingId.value === id) return 1000
|
|
49
|
+
if (hoveredFrameId.value === id) return 100
|
|
50
|
+
return 1
|
|
82
51
|
}
|
|
83
52
|
|
|
84
53
|
const nodes = computed(() => [
|
|
85
|
-
...board.frames.map((b) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
}),
|
|
54
|
+
...board.frames.map((b) => ({
|
|
55
|
+
id: b.id,
|
|
56
|
+
type: 'block',
|
|
57
|
+
position: { x: b.position.x, y: b.position.y },
|
|
58
|
+
// Always-expanded frames fill the viewport; keep them non-draggable so the pane
|
|
59
|
+
// pans through them (they move via their header handle, see BlockNode).
|
|
60
|
+
draggable: false,
|
|
61
|
+
zIndex: frameZIndex(b.id),
|
|
62
|
+
data: {},
|
|
63
|
+
})),
|
|
97
64
|
// Epics are top-level grouping nodes (non-structural), drawn alongside frames and
|
|
98
65
|
// linked to their member tasks by the dependency-edge overlay.
|
|
99
|
-
...board.epics.map((b) => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
data: {},
|
|
107
|
-
}
|
|
108
|
-
}),
|
|
66
|
+
...board.epics.map((b) => ({
|
|
67
|
+
id: b.id,
|
|
68
|
+
type: 'epic',
|
|
69
|
+
position: { x: b.position.x, y: b.position.y },
|
|
70
|
+
draggable: true,
|
|
71
|
+
data: {},
|
|
72
|
+
})),
|
|
109
73
|
])
|
|
110
74
|
|
|
111
75
|
onNodeDragStop(({ node }) => {
|
|
112
|
-
|
|
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 })
|
|
76
|
+
board.moveBlock(node.id, node.position)
|
|
116
77
|
})
|
|
117
78
|
|
|
118
79
|
onViewportChange((vp) => {
|
|
@@ -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
|
|
95
|
-
//
|
|
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
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
3
|
+
"version": "0.30.5",
|
|
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
|
-
}
|