@cat-factory/app 0.17.0 → 0.17.2

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.
@@ -9,6 +9,7 @@ import { STATUS_META } from '~/utils/catalog'
9
9
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
10
10
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
11
11
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
12
+ import { useFrameExpansion } from '~/composables/useFrameExpansion'
12
13
 
13
14
  const board = useBoardStore()
14
15
  const pipelines = usePipelinesStore()
@@ -21,8 +22,11 @@ const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(
21
22
 
22
23
  // Gate which task cards expand their pipeline list on deep zoom: only on-screen
23
24
  // cards, and only the centre-most of any that would overlap (see useTaskExpansion).
25
+ // The frame-level gate is the same idea one level up: which service frames may
26
+ // auto-expand to their task canvas once zoomed in (see useFrameExpansion).
24
27
  const boardEl = ref<HTMLElement | null>(null)
25
28
  useTaskExpansion(boardEl)
29
+ useFrameExpansion(boardEl)
26
30
 
27
31
  // Only frames are board nodes. Dependencies live on tasks (rendered inside the
28
32
  // frames), so there are no frame-to-frame edges on the canvas.
@@ -0,0 +1,118 @@
1
+ import type { Ref } from 'vue'
2
+ import { onMounted, onBeforeUnmount } from 'vue'
3
+ import { useRafFn } from '@vueuse/core'
4
+ import { lodAtLeast } from '~/composables/useSemanticZoom'
5
+
6
+ type Rect = { left: number; right: number; top: number; bottom: number }
7
+
8
+ function intersects(a: Rect, b: Rect) {
9
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
10
+ }
11
+
12
+ function sameSet(a: Set<string>, b: Set<string>) {
13
+ if (a.size !== b.size) return false
14
+ for (const id of a) if (!b.has(id)) return false
15
+ return true
16
+ }
17
+
18
+ /**
19
+ * Board-level driver deciding which service frames may auto-expand to their task
20
+ * canvas once zoomed past the `close` band. The frame analogue of
21
+ * `useTaskExpansion`: two gates, recomputed every frame against live DOM rects so
22
+ * they follow pan / zoom / drag / resize:
23
+ *
24
+ * - visibility: a frame expands only while its card overlaps the board viewport,
25
+ * so a service that isn't on screen at all never expands when you zoom in.
26
+ * - overlap: walking the visible frames nearest-to-screen-centre first, a frame
27
+ * expands only if its (expanded) footprint doesn't collide with one already
28
+ * granted — so the small service the user centred on wins, and a larger
29
+ * neighbour can't "snap out" over it.
30
+ *
31
+ * Writes the permitted id set into the `frameExpansion` store; `ui.isFrameExpanded`
32
+ * reads it. Manually-expanded frames bypass this gate entirely (see the store).
33
+ */
34
+ export function useFrameExpansion(container: Ref<HTMLElement | null>) {
35
+ const board = useBoardStore()
36
+ const ui = useUiStore()
37
+ const store = useFrameExpansionStore()
38
+
39
+ // Last-known expanded size per frame. A frame's card balloons from a chip to its
40
+ // full task canvas only while it's granted, so its live rect collapses the moment
41
+ // it's denied. Testing overlap with the collapsed chip is what would cause
42
+ // flashing: a denied frame no longer overlaps its neighbour, gets re-granted,
43
+ // expands, overlaps again, gets denied — every frame. We cache the expanded
44
+ // extent while a frame is granted and project the footprint with it, so a denied
45
+ // frame is still tested at its expanded size and stays denied. Stable.
46
+ const expandedSize = new Map<string, { w: number; h: number }>()
47
+
48
+ function rectOf(id: string): DOMRect | null {
49
+ const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
50
+ return el ? el.getBoundingClientRect() : null
51
+ }
52
+
53
+ function recompute() {
54
+ // Frames only auto-expand at the `close` band and deeper; clear otherwise.
55
+ if (!lodAtLeast(ui.lod, 'close')) {
56
+ if (store.allowed.size) store.setAllowed(new Set())
57
+ return
58
+ }
59
+ const view = container.value?.getBoundingClientRect()
60
+ if (!view) return
61
+ const cx = view.left + view.width / 2
62
+ const cy = view.top + view.height / 2
63
+
64
+ const candidates: { id: string; rect: Rect; dist: number }[] = []
65
+ const liveIds = new Set<string>()
66
+ for (const f of board.frames) {
67
+ const rect = rectOf(f.id)
68
+ if (!rect) continue
69
+ liveIds.add(f.id)
70
+ // While granted the frame is rendered expanded, so its live rect is its
71
+ // expanded footprint — cache it. A denied frame keeps its last cached value.
72
+ if (store.allowed.has(f.id)) expandedSize.set(f.id, { w: rect.width, h: rect.height })
73
+ // Visibility: the card must intersect the board viewport (live rect).
74
+ if (!intersects(rect, view)) continue
75
+ // Project to the cached expanded extent so the overlap test is independent of
76
+ // the card's current (possibly collapsed-chip) state. A frame grows rightward
77
+ // and downward from its top-left, which stays put as it expands.
78
+ const cached = expandedSize.get(f.id)
79
+ const width = Math.max(rect.width, cached?.w ?? 0)
80
+ const height = Math.max(rect.height, cached?.h ?? 0)
81
+ const footprint: Rect = {
82
+ left: rect.left,
83
+ right: rect.left + width,
84
+ top: rect.top,
85
+ bottom: rect.top + height,
86
+ }
87
+ // Stable anchor: the card's top-left. It doesn't move as the frame grows, so
88
+ // the ordering can't oscillate as frames expand / collapse.
89
+ const dist = (rect.left - cx) ** 2 + (rect.top - cy) ** 2
90
+ candidates.push({ id: f.id, rect: footprint, dist })
91
+ }
92
+ // Drop cached sizes for frames that are gone, so the map can't grow unbounded.
93
+ for (const id of expandedSize.keys()) if (!liveIds.has(id)) expandedSize.delete(id)
94
+ candidates.sort((a, b) => a.dist - b.dist)
95
+
96
+ // Greedy by distance to centre: a frame is granted only if its projected
97
+ // footprint clears every footprint already granted, so the centre-most frame
98
+ // wins any overlap.
99
+ const claimed: Rect[] = []
100
+ const next = new Set<string>()
101
+ for (const c of candidates) {
102
+ if (claimed.some((r) => intersects(c.rect, r))) continue
103
+ next.add(c.id)
104
+ claimed.push(c.rect)
105
+ }
106
+ if (!sameSet(next, store.allowed)) store.setAllowed(next)
107
+ }
108
+
109
+ const { pause, resume } = useRafFn(recompute, { immediate: false })
110
+ onMounted(() => {
111
+ store.setDriverActive(true)
112
+ resume()
113
+ })
114
+ onBeforeUnmount(() => {
115
+ pause()
116
+ store.setDriverActive(false)
117
+ })
118
+ }
@@ -35,6 +35,15 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
35
35
  const ui = useUiStore()
36
36
  const store = useTaskExpansionStore()
37
37
 
38
+ // Last-known expanded height per task. A card grows downward only while it's
39
+ // granted (its pipeline list is rendered), so its live height collapses the
40
+ // moment it's denied. Testing overlap with that collapsed height is what causes
41
+ // the flashing: a denied card no longer overlaps its neighbour, gets re-granted,
42
+ // expands, overlaps again, gets denied — every frame. We cache the expanded
43
+ // height while a card is granted and project the footprint with it, so a denied
44
+ // card is still tested at its expanded extent and stays denied. Stable.
45
+ const expandedHeight = new Map<string, number>()
46
+
38
47
  function rectOf(id: string): DOMRect | null {
39
48
  const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
40
49
  return el ? el.getBoundingClientRect() : null
@@ -51,26 +60,43 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
51
60
  const cx = view.left + view.width / 2
52
61
  const cy = view.top + view.height / 2
53
62
 
54
- const candidates: { id: string; rect: DOMRect; dist: number }[] = []
63
+ const candidates: { id: string; rect: Rect; dist: number }[] = []
64
+ const liveIds = new Set<string>()
55
65
  for (const t of board.allTasks) {
56
66
  // Only tasks whose run actually has steps would expand a pipeline list.
57
67
  if (!execution.getByBlock(t.id)?.steps.length) continue
58
68
  const rect = rectOf(t.id)
59
69
  if (!rect) continue
60
- // Visibility: the card must intersect the board viewport.
70
+ liveIds.add(t.id)
71
+ // While a card is granted it's rendered expanded, so its live height is its
72
+ // expanded footprint — cache it. A denied card keeps its last cached value.
73
+ if (store.allowed.has(t.id)) expandedHeight.set(t.id, rect.height)
74
+ // Visibility: the card must intersect the board viewport (live rect).
61
75
  if (!intersects(rect, view)) continue
76
+ // Project the footprint downward to the expanded extent so the overlap test
77
+ // is independent of the card's current (possibly collapsed) state.
78
+ const height = Math.max(rect.height, expandedHeight.get(t.id) ?? 0)
79
+ const footprint: Rect = {
80
+ left: rect.left,
81
+ right: rect.right,
82
+ top: rect.top,
83
+ bottom: rect.top + height,
84
+ }
62
85
  // Stable anchor: the card's top-centre. It doesn't move as the card grows
63
86
  // downward, so the ordering can't oscillate as cards expand / collapse.
64
87
  const ax = rect.left + rect.width / 2
65
88
  const ay = rect.top
66
89
  const dist = (ax - cx) ** 2 + (ay - cy) ** 2
67
- candidates.push({ id: t.id, rect, dist })
90
+ candidates.push({ id: t.id, rect: footprint, dist })
68
91
  }
92
+ // Drop cached heights for cards that are gone, so the map can't grow unbounded.
93
+ for (const id of expandedHeight.keys()) if (!liveIds.has(id)) expandedHeight.delete(id)
69
94
  candidates.sort((a, b) => a.dist - b.dist)
70
95
 
71
- // Greedy by distance to centre: a candidate is granted only if its rect clears
72
- // every rect already granted, so the centre-most card wins any overlap.
73
- const claimed: DOMRect[] = []
96
+ // Greedy by distance to centre: a candidate is granted only if its projected
97
+ // footprint clears every footprint already granted, so the centre-most card
98
+ // wins any overlap.
99
+ const claimed: Rect[] = []
74
100
  const next = new Set<string>()
75
101
  for (const c of candidates) {
76
102
  if (claimed.some((r) => intersects(c.rect, r))) continue
@@ -0,0 +1,38 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+
4
+ /**
5
+ * Which service frames may auto-expand to reveal their tasks once zoomed in.
6
+ *
7
+ * Past the `close` band a frame opens from a chip to its full task canvas, which
8
+ * can balloon across the viewport. Expanding *every* frame at once (the old
9
+ * behaviour) made a large off-centre service "snap out" over the small one the
10
+ * user was actually centred on, and expanded services that weren't even on screen.
11
+ * The board driver (`useFrameExpansion`) recomputes a permitted set every frame —
12
+ * only on-screen frames, and only the one closest to the screen centre when two
13
+ * expanded footprints would overlap — and writes it here. `ui.isFrameExpanded`
14
+ * reads `canExpand` to decide whether the zoom band may open a frame.
15
+ *
16
+ * `driverActive` lets the gate degrade gracefully: with no board driver mounted
17
+ * (e.g. the focus view, or a frame rendered in isolation / tests) `canExpand`
18
+ * falls back to "allowed", so the plain zoom behaviour is unchanged.
19
+ */
20
+ export const useFrameExpansionStore = defineStore('frameExpansion', () => {
21
+ const allowed = ref<Set<string>>(new Set())
22
+ const driverActive = ref(false)
23
+
24
+ function setAllowed(ids: Set<string>) {
25
+ allowed.value = ids
26
+ }
27
+
28
+ function setDriverActive(active: boolean) {
29
+ driverActive.value = active
30
+ if (!active) allowed.value = new Set()
31
+ }
32
+
33
+ function canExpand(id: string) {
34
+ return driverActive.value ? allowed.value.has(id) : true
35
+ }
36
+
37
+ return { allowed, driverActive, setAllowed, setDriverActive, canExpand }
38
+ })
package/app/stores/ui.ts CHANGED
@@ -4,6 +4,7 @@ import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domai
4
4
  import type { PendingContext } from '~/composables/useContextLinking'
5
5
  import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
6
6
  import { useExecutionStore } from '~/stores/execution'
7
+ import { useFrameExpansionStore } from '~/stores/frameExpansion'
7
8
  import { agentKindMeta } from '~/utils/catalog'
8
9
 
9
10
  /** Values used to seed the add-task form when it is opened from another surface. */
@@ -159,9 +160,14 @@ export const useUiStore = defineStore('ui', () => {
159
160
  }
160
161
 
161
162
  /** A frame shows its tasks when manually expanded OR once zoomed in to `close`
162
- * or any deeper band (`steps`/`subtasks` drill further into those tasks). */
163
+ * or any deeper band (`steps`/`subtasks` drill further into those tasks). The
164
+ * zoom-driven branch is gated by the board's frame-expansion driver so only
165
+ * on-screen, centre-most frames open — a large off-centre or off-screen service
166
+ * no longer snaps out over the one the user is focused on. The gate degrades to
167
+ * "allowed" when no board driver is mounted (focus view / tests). */
163
168
  function isFrameExpanded(id: string) {
164
- return expandedFrames.value.has(id) || lodAtLeast(lod.value, 'close')
169
+ if (expandedFrames.value.has(id)) return true
170
+ return lodAtLeast(lod.value, 'close') && useFrameExpansionStore().canExpand(id)
165
171
  }
166
172
 
167
173
  function select(id: string | null) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.17.0",
3
+ "version": "0.17.2",
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",