@cat-factory/app 0.26.0 → 0.26.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.
- package/app/components/board/BoardCanvas.vue +77 -28
- package/app/components/board/nodes/BlockNode.vue +8 -4
- package/app/components/board/nodes/EpicNode.vue +3 -5
- package/app/components/layout/ProviderConfigBanner.vue +5 -1
- package/app/components/panels/inspector/EpicChildren.vue +13 -3
- package/app/components/panels/inspector/TaskRunSettings.vue +2 -2
- package/app/components/settings/ProviderConnectionPanel.vue +12 -4
- package/app/components/tasks/TaskSourceConnectModal.vue +2 -1
- package/app/composables/api/reviews.ts +1 -6
- package/app/composables/useTaskExpansion.ts +10 -5
- package/app/stores/ui.ts +7 -11
- package/app/utils/boardDisplacement.spec.ts +63 -0
- package/app/utils/boardDisplacement.ts +62 -0
- package/package.json +1 -1
- package/app/composables/useFrameExpansion.ts +0 -118
- package/app/stores/frameExpansion.ts +0 -38
|
@@ -11,7 +11,7 @@ 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 { computeDisplacement } from '~/utils/boardDisplacement'
|
|
15
15
|
|
|
16
16
|
const board = useBoardStore()
|
|
17
17
|
const pipelines = usePipelinesStore()
|
|
@@ -22,48 +22,97 @@ const toast = useToast()
|
|
|
22
22
|
|
|
23
23
|
const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
|
|
24
24
|
|
|
25
|
-
// Gate which task cards expand their pipeline list on deep zoom:
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// auto-expand to their task canvas once zoomed in (see useFrameExpansion).
|
|
25
|
+
// Gate which task cards expand their pipeline list on deep zoom: on-screen, and the
|
|
26
|
+
// 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).
|
|
29
28
|
const boardEl = ref<HTMLElement | null>(null)
|
|
30
29
|
useTaskExpansion(boardEl)
|
|
31
|
-
useFrameExpansion(boardEl)
|
|
32
30
|
|
|
33
31
|
// Only frames are board nodes. Dependencies live on tasks (rendered inside the
|
|
34
32
|
// frames), so there are no frame-to-frame edges on the canvas.
|
|
35
33
|
//
|
|
36
34
|
// Vue Flow tags every *draggable* node with the `nopan` class, which makes the
|
|
37
|
-
// pane refuse to pan while the pointer is over it.
|
|
38
|
-
// of the viewport, so leaving
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
35
|
+
// pane refuse to pan while the pointer is over it. Service frames are always expanded
|
|
36
|
+
// and fill much of the viewport, so leaving them draggable would turn the whole canvas
|
|
37
|
+
// into a dead zone. We therefore make every frame non-draggable (the pane pans straight
|
|
38
|
+
// 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 }
|
|
44
82
|
}
|
|
45
83
|
|
|
46
84
|
const nodes = computed(() => [
|
|
47
|
-
...board.frames.map((b) =>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}),
|
|
54
97
|
// Epics are top-level grouping nodes (non-structural), drawn alongside frames and
|
|
55
98
|
// linked to their member tasks by the dependency-edge overlay.
|
|
56
|
-
...board.epics.map((b) =>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
+
}),
|
|
63
109
|
])
|
|
64
110
|
|
|
65
111
|
onNodeDragStop(({ node }) => {
|
|
66
|
-
|
|
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 })
|
|
67
116
|
})
|
|
68
117
|
|
|
69
118
|
onViewportChange((vp) => {
|
|
@@ -53,9 +53,11 @@ const FRAME_LABEL: Record<BlockStatus, string> = {
|
|
|
53
53
|
const statusLabel = computed(() => FRAME_LABEL[frameStatus.value])
|
|
54
54
|
|
|
55
55
|
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
|
|
56
|
+
// Services are always expanded to their task canvas, at every zoom level: there is no
|
|
57
|
+
// chip/compact collapse, so panning is a fixed layout and zooming has no expand/collapse
|
|
58
|
+
// transition to snap on. The far-chip and compact-summary branches in the template are
|
|
59
|
+
// kept (gated off) so the prior behaviour is one edit away if we want chips back.
|
|
60
|
+
const showExpanded = computed(() => true)
|
|
59
61
|
|
|
60
62
|
// Surface a pending decision from this frame OR any of its tasks.
|
|
61
63
|
const blockDecisions = computed(() =>
|
|
@@ -179,8 +181,10 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
179
181
|
</div>
|
|
180
182
|
|
|
181
183
|
<!-- ===================== FAR: glanceable chip ===================== -->
|
|
184
|
+
<!-- Inert while services are always expanded (showExpanded is always true); the
|
|
185
|
+
compact branch below is reached via v-else-if and is likewise inert. -->
|
|
182
186
|
<div
|
|
183
|
-
v-if="lod === 'far'"
|
|
187
|
+
v-if="!showExpanded && lod === 'far'"
|
|
184
188
|
class="flex w-44 items-center gap-2 rounded-xl border-2 px-3 py-3 shadow-lg backdrop-blur"
|
|
185
189
|
:class="[selected ? 'border-white' : '', pulseClass]"
|
|
186
190
|
:style="{ borderColor: accent, backgroundColor: accent + '26' }"
|
|
@@ -15,8 +15,8 @@ const block = computed<Block | undefined>(() => board.getBlock(props.id))
|
|
|
15
15
|
const members = computed(() => board.epicMembers(props.id))
|
|
16
16
|
const total = computed(() => members.value.length)
|
|
17
17
|
const done = computed(() => members.value.filter((m) => m.status === 'done').length)
|
|
18
|
-
const active = computed(
|
|
19
|
-
members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
|
|
18
|
+
const active = computed(
|
|
19
|
+
() => members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
|
|
20
20
|
)
|
|
21
21
|
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
22
22
|
</script>
|
|
@@ -26,9 +26,7 @@ const selected = computed(() => ui.selectedBlockId === props.id)
|
|
|
26
26
|
v-if="block"
|
|
27
27
|
:data-block-id="block.id"
|
|
28
28
|
class="w-56 cursor-pointer rounded-lg border bg-slate-900/90 px-3 py-2 shadow-lg backdrop-blur transition-colors"
|
|
29
|
-
:class="
|
|
30
|
-
selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'
|
|
31
|
-
"
|
|
29
|
+
:class="selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'"
|
|
32
30
|
@click="ui.select(block.id)"
|
|
33
31
|
>
|
|
34
32
|
<div class="flex items-center gap-1.5">
|
|
@@ -42,7 +42,11 @@ const show = computed(() => pending.value.length > 0 && !dismissed.value)
|
|
|
42
42
|
<div class="min-w-0 flex-1">
|
|
43
43
|
<div class="flex items-start justify-between gap-3">
|
|
44
44
|
<h2 class="text-lg font-semibold text-amber-100">
|
|
45
|
-
{{
|
|
45
|
+
{{
|
|
46
|
+
pending.length > 1
|
|
47
|
+
? 'Providers need configuration'
|
|
48
|
+
: `${LABEL[pending[0]!]} needs configuration`
|
|
49
|
+
}}
|
|
46
50
|
</h2>
|
|
47
51
|
<UButton
|
|
48
52
|
color="neutral"
|
|
@@ -18,7 +18,10 @@ const done = computed(() => members.value.filter((m) => m.status === 'done').len
|
|
|
18
18
|
const groups = computed(() => {
|
|
19
19
|
const byService = new Map<
|
|
20
20
|
string,
|
|
21
|
-
{
|
|
21
|
+
{
|
|
22
|
+
service: Block | undefined
|
|
23
|
+
modules: Map<string, { module: Block | undefined; tasks: Block[] }>
|
|
24
|
+
}
|
|
22
25
|
>()
|
|
23
26
|
for (const task of members.value) {
|
|
24
27
|
const service = board.serviceOf(task)
|
|
@@ -29,7 +32,10 @@ const groups = computed(() => {
|
|
|
29
32
|
const parent = task.parentId ? board.getBlock(task.parentId) : undefined
|
|
30
33
|
const moduleKey = parent && parent.level === 'module' ? parent.id : '—'
|
|
31
34
|
if (!group.modules.has(moduleKey)) {
|
|
32
|
-
group.modules.set(moduleKey, {
|
|
35
|
+
group.modules.set(moduleKey, {
|
|
36
|
+
module: parent?.level === 'module' ? parent : undefined,
|
|
37
|
+
tasks: [],
|
|
38
|
+
})
|
|
33
39
|
}
|
|
34
40
|
group.modules.get(moduleKey)!.tasks.push(task)
|
|
35
41
|
}
|
|
@@ -51,7 +57,11 @@ const groups = computed(() => {
|
|
|
51
57
|
</div>
|
|
52
58
|
|
|
53
59
|
<div v-else class="space-y-2">
|
|
54
|
-
<div
|
|
60
|
+
<div
|
|
61
|
+
v-for="(group, gi) in groups"
|
|
62
|
+
:key="gi"
|
|
63
|
+
class="rounded-md border border-slate-700/60 p-2"
|
|
64
|
+
>
|
|
55
65
|
<div class="mb-1 flex items-center gap-1 text-[11px] font-medium text-slate-300">
|
|
56
66
|
<UIcon name="i-lucide-box" class="h-3 w-3 text-slate-500" />
|
|
57
67
|
{{ group.service?.title ?? 'Unassigned' }}
|
|
@@ -444,8 +444,8 @@ const technicalLabel = computed(() => {
|
|
|
444
444
|
/>
|
|
445
445
|
</div>
|
|
446
446
|
<div class="mt-1 text-[11px] text-slate-500">
|
|
447
|
-
When this task merges, automatically start the tasks that depend on it (once their
|
|
448
|
-
|
|
447
|
+
When this task merges, automatically start the tasks that depend on it (once their other
|
|
448
|
+
dependencies are also done).
|
|
449
449
|
</div>
|
|
450
450
|
</div>
|
|
451
451
|
</div>
|
|
@@ -107,13 +107,16 @@ const canSave = computed(() => {
|
|
|
107
107
|
* `manifestTemplate` scaffold on a first connect. Native providers only (a manifest provider
|
|
108
108
|
* has no template ⇒ null, and rotates secrets via the dedicated path instead).
|
|
109
109
|
*/
|
|
110
|
-
function buildManifestPayload(): {
|
|
110
|
+
function buildManifestPayload(): {
|
|
111
|
+
manifest: Record<string, unknown>
|
|
112
|
+
secrets: Record<string, string>
|
|
113
|
+
} | null {
|
|
111
114
|
const template = descriptor.value?.manifestTemplate
|
|
112
115
|
if (!template) return null
|
|
113
116
|
const base = descriptor.value?.savedManifest ?? template
|
|
114
117
|
const manifest: Record<string, unknown> = structuredClone(base)
|
|
115
118
|
const providerConfig: Record<string, unknown> = {
|
|
116
|
-
...(
|
|
119
|
+
...(manifest.providerConfig as Record<string, unknown> | undefined),
|
|
117
120
|
}
|
|
118
121
|
const secrets: Record<string, string> = {}
|
|
119
122
|
for (const f of descriptor.value?.configFields ?? []) {
|
|
@@ -249,7 +252,10 @@ function fieldHelp(key: string): string | undefined {
|
|
|
249
252
|
</p>
|
|
250
253
|
<!-- A native re-register replaces the whole manifest; secrets are write-only so they
|
|
251
254
|
must be re-supplied. Non-secret config is prefilled, so it survives a save. -->
|
|
252
|
-
<p
|
|
255
|
+
<p
|
|
256
|
+
v-if="connection && canAuthor && hasSecretFields"
|
|
257
|
+
class="text-[11px] text-amber-300/80"
|
|
258
|
+
>
|
|
253
259
|
Re-enter the secret field{{ secretFieldCount > 1 ? 's' : '' }} to save changes — stored
|
|
254
260
|
secrets are write-only and aren't shown.
|
|
255
261
|
</p>
|
|
@@ -257,7 +263,9 @@ function fieldHelp(key: string): string | undefined {
|
|
|
257
263
|
<UFormField
|
|
258
264
|
v-for="field in descriptor.configFields"
|
|
259
265
|
:key="field.key"
|
|
260
|
-
:label="
|
|
266
|
+
:label="
|
|
267
|
+
field.label + (field.required && field.default === undefined ? '' : ' (optional)')
|
|
268
|
+
"
|
|
261
269
|
:help="fieldHelp(field.key)"
|
|
262
270
|
>
|
|
263
271
|
<USelect
|
|
@@ -117,7 +117,8 @@ async function toggleEnabled(enabled: boolean) {
|
|
|
117
117
|
credentials to enter.
|
|
118
118
|
</p>
|
|
119
119
|
<p v-if="!available" class="text-[11px] text-amber-400">
|
|
120
|
-
Install the workspace's GitHub App (connect GitHub repos) to offer
|
|
120
|
+
Install the workspace's GitHub App (connect GitHub repos) to offer
|
|
121
|
+
{{ descriptor.label }}.
|
|
121
122
|
</p>
|
|
122
123
|
</template>
|
|
123
124
|
|
|
@@ -104,12 +104,7 @@ export function reviewsApi({ http, ws }: ApiContext) {
|
|
|
104
104
|
{ method: 'POST' },
|
|
105
105
|
),
|
|
106
106
|
|
|
107
|
-
reRequestRecommendation: (
|
|
108
|
-
workspaceId: string,
|
|
109
|
-
reviewId: string,
|
|
110
|
-
recId: string,
|
|
111
|
-
note: string,
|
|
112
|
-
) =>
|
|
107
|
+
reRequestRecommendation: (workspaceId: string, reviewId: string, recId: string, note: string) =>
|
|
113
108
|
http<RequirementReview>(
|
|
114
109
|
`${ws(workspaceId)}/requirement-reviews/${encodeURIComponent(reviewId)}/recommendations/${encodeURIComponent(recId)}/re-request`,
|
|
115
110
|
{ method: 'POST', body: { note } },
|
|
@@ -82,11 +82,16 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
82
82
|
top: rect.top,
|
|
83
83
|
bottom: rect.top + height,
|
|
84
84
|
}
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
90
95
|
candidates.push({ id: t.id, rect: footprint, dist })
|
|
91
96
|
}
|
|
92
97
|
// Drop cached heights for cards that are gone, so the map can't grow unbounded.
|
package/app/stores/ui.ts
CHANGED
|
@@ -2,9 +2,8 @@ import { defineStore } from 'pinia'
|
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
3
|
import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
|
|
4
4
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
5
|
-
import { zoomToLod
|
|
5
|
+
import { zoomToLod } from '~/composables/useSemanticZoom'
|
|
6
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
7
|
-
import { useFrameExpansionStore } from '~/stores/frameExpansion'
|
|
8
7
|
import { agentKindMeta } from '~/utils/catalog'
|
|
9
8
|
|
|
10
9
|
/** Values used to seed the add-task form when it is opened from another surface. */
|
|
@@ -163,15 +162,12 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
163
162
|
expandedFrames.value = new Set(expandedFrames.value).add(id)
|
|
164
163
|
}
|
|
165
164
|
|
|
166
|
-
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
function isFrameExpanded(id: string) {
|
|
173
|
-
if (expandedFrames.value.has(id)) return true
|
|
174
|
-
return lodAtLeast(lod.value, 'close') && useFrameExpansionStore().canExpand(id)
|
|
165
|
+
/** Services are always expanded to their task canvas, at every zoom level, so the
|
|
166
|
+
* board layout is fixed: panning never changes it and zooming has no expand/collapse
|
|
167
|
+
* transition to snap on. (`expandedFrames`/`toggleFrame` are retained for callers but
|
|
168
|
+
* no longer gate rendering.) */
|
|
169
|
+
function isFrameExpanded(_id: string) {
|
|
170
|
+
return true
|
|
175
171
|
}
|
|
176
172
|
|
|
177
173
|
function select(id: string | null) {
|
|
@@ -0,0 +1,63 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.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",
|
|
@@ -1,118 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
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
|
-
})
|