@cat-factory/app 0.286.6 → 0.288.0

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/README.md CHANGED
@@ -342,6 +342,47 @@ projects through kernel's `applyMountLayout`. The resize path is where people hi
342
342
  because a `size`-only edit is the one frame patch with no other visible effect: the SPA upserts the
343
343
  authoritative block the mutation returned and the frame jumps to coordinates no board shows it at.
344
344
 
345
+ **No two top-level board nodes may overlap**, and that is a standing invariant rather than a rule
346
+ each write remembers. `useFrameOverlapGuard` (mounted by `BoardCanvas`) watches the rendered
347
+ geometry of every frame and epic and, whenever two come to overlap, bounces them apart through
348
+ `framePlacement.resolveFrameOverlaps`. Placement alone was not enough: `findFreeFramePosition`
349
+ refuses to CREATE an overlap, but three later events make one anyway, and only one of them is a
350
+ drag. A border drag grows a frame into its neighbour, and a frame GROWS ON ITS OWN when its first
351
+ task arrives, because an empty service renders the "add the first task" panel and reserves a much
352
+ smaller footprint than one rendering lanes. Watching the geometry covers all three, and covers the
353
+ next write nobody has thought of yet.
354
+
355
+ Three things about it are load-bearing:
356
+
357
+ - **It runs in the SPA because only the SPA can measure a frame.** The footprint is derived from
358
+ the lane geometry the browser renders at (`containerSize`); the backend stores a position and at
359
+ most a size override, so it cannot tell whether two frames overlap.
360
+ - **Correcting the VIEW and WRITING the correction are separate, and only a local gesture
361
+ authorises the write.** Every client always draws the board clear, which needs no coordination
362
+ because `resolveFrameOverlaps` is pure: two browsers holding one board draw it identically
363
+ whatever order their events arrived in, and a read-only viewer gets the corrected view for free.
364
+ Persisting is the narrower act, and a drag or border resize is the only cause with an
365
+ unambiguous single author, so that client settles the board when the gesture ends and writes what
366
+ it displaced. A frame that grew ON ITS OWN has no author, so its correction is drawn everywhere
367
+ and written by nobody: better than every open session racing to persist the same value, and
368
+ better behaved besides, since a projected neighbour comes back off the server's own geometry when
369
+ the frame shrinks again while a persisted one would stay pushed.
370
+ - **The settlement ORDER is the whole policy** (`bySettlementOrder`), and it takes at most ONE
371
+ anchor: the node the local user is placing, held still while its neighbours move aside. A LIST of
372
+ anchors would carry an order of its own, and every order available to build one from is
373
+ per-client (the sequence a client's live events arrived in, or a history only the client that
374
+ watched the change has), so two clients would resolve one overlap to different positions and
375
+ write over each other. Everything else settles in READING ORDER, which is POSITIONAL: the node
376
+ nearest the top-left keeps its place. Note that this is not "the newcomer yields to the frames
377
+ already there": a block carries no shared creation stamp, so arrival is knowable only from a
378
+ client's own session, which is the per-client input this rules out.
379
+
380
+ The guard stands down for the whole of a gesture. A drag previews a position on every pointer move,
381
+ and bouncing neighbours off those in-flight positions displaces frames the user is merely passing
382
+ OVER: each pass reads the neighbour where the previous pass pushed it, so the displacement
383
+ accumulates instead of springing back. A frame drawn over its neighbours while the pointer holds it
384
+ is what direct manipulation looks like; the board settles on release.
385
+
345
386
  **Dragging a card now only reparents** (`positioned: false` in `useBlockDrag`): between services,
346
387
  and into or out of a module via a module group header's drop zone. Which LANE a card is in is not
347
388
  something a drop can decide — the lane is derived from state, so dropping a not-started card on
@@ -12,6 +12,7 @@ import { useTaskExpansion } from '~/composables/useTaskExpansion'
12
12
  import { useBlockDrag } from '~/composables/useBlockDrag'
13
13
  import { useFrameStacking } from '~/composables/useFrameStacking'
14
14
  import { useFramePlacement } from '~/composables/useFramePlacement'
15
+ import { useFrameOverlapGuard } from '~/composables/useFrameOverlapGuard'
15
16
  import { useViewport } from '~/composables/useViewport'
16
17
  import { boardPanMode } from '~/utils/boardPanMode'
17
18
  import { createBoardNodeProjection } from './BoardCanvas.logic'
@@ -32,6 +33,11 @@ const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(
32
33
  const { draggingId } = useBlockDrag()
33
34
  const { hoveredFrameId } = useFrameStacking()
34
35
  const { freeFramePosition, focusFrame } = useFramePlacement()
36
+ // The board's "no two top-level nodes overlap" invariant: whenever a frame comes to overlap a
37
+ // neighbour (dragged onto it, grown into it by a border drag, or grown by its first task), the two
38
+ // are bounced apart. Every client draws that correction; only the one whose own gesture caused the
39
+ // overlap writes it back. See useFrameOverlapGuard.
40
+ useFrameOverlapGuard()
35
41
  // Touch drives the canvas gestures: a touch-capable surface needs one-finger pan.
36
42
  // We gate on `hasTouch` (any-pointer: coarse), not `isTouch` (the *primary* pointer),
37
43
  // so a touchscreen laptop / 2-in-1 — whose primary pointer is the trackpad — still
@@ -62,10 +68,11 @@ useTaskExpansion(boardEl, boardActivity)
62
68
  // into a dead zone. We therefore make every frame non-draggable (the pane pans straight
63
69
  // through it) and move it via its header handle instead.
64
70
  //
65
- // Frames are rendered exactly at their stored position and may overlap freely
66
- // moving one never shifts another. The frame being dragged is lifted to the top,
67
- // then the hovered frame (the un-obscured one under the pointer), so overlapping
68
- // services can always be reached and reordered. See useFrameStacking.
71
+ // Frames are rendered at the position the overlap guard keeps clear of every other top-level
72
+ // node, so a frame is never hidden behind a neighbour once it has settled. Stacking still decides
73
+ // what is on top of what while a drag is in flight (the guard stands down for the whole gesture,
74
+ // so a dragged frame does cross its neighbours) and for the frame chrome that extends past the
75
+ // box: the dragged frame is lifted to the top, then the hovered one. See useFrameStacking.
69
76
  //
70
77
  // `elevate-nodes-on-select` is turned OFF on <VueFlow> for this to work: Vue Flow's
71
78
  // default adds +1000 to a selected node's z-index, so a frame stayed pinned on top
@@ -5,6 +5,7 @@ import { lastCompleteRollupDay } from '@cat-factory/contracts'
5
5
  import type {
6
6
  ReportActivityDimension,
7
7
  ReportActivityRow,
8
+ ReportSpendDimension,
8
9
  ReportSpendRow,
9
10
  ReportWindow,
10
11
  } from '~/types/execution'
@@ -56,12 +57,14 @@ const WINDOWS: { value: ReportWindow; label: string }[] = [
56
57
  { value: '90d', label: t('reports.window.ninetyDays') },
57
58
  ]
58
59
 
59
- // The dimension the paired spend + activity breakdowns are grouped by. Model and agent
60
- // kind have no activity counterpart (a run carries no single kind), so they render
61
- // unconditionally above rather than joining this switch.
60
+ // The dimension the paired spend + activity breakdowns are grouped by. Model and agent kind
61
+ // have no activity counterpart (a run carries no single kind), and neither do ticket and run
62
+ // (a ticket counts no runs of its own, and a run IS the unit), so those four render as
63
+ // spend-only cards rather than joining this switch.
62
64
  const DIMENSIONS: { value: ReportActivityDimension; label: string }[] = [
63
65
  { value: 'workspace', label: t('reports.dimension.workspace') },
64
66
  { value: 'service', label: t('reports.dimension.service') },
67
+ { value: 'repo', label: t('reports.dimension.repo') },
65
68
  { value: 'taskType', label: t('reports.dimension.taskType') },
66
69
  ]
67
70
  const dimension = ref<ReportActivityDimension>('workspace')
@@ -80,6 +83,7 @@ const spendByDimension = computed<ReportSpendRow[]>(() => {
80
83
  if (!spend) return []
81
84
  if (dimension.value === 'workspace') return spend.byWorkspace
82
85
  if (dimension.value === 'service') return spend.byService
86
+ if (dimension.value === 'repo') return spend.byRepo
83
87
  return spend.byTaskType
84
88
  })
85
89
  const activityByDimension = computed<ReportActivityRow[]>(() => {
@@ -87,9 +91,19 @@ const activityByDimension = computed<ReportActivityRow[]>(() => {
87
91
  if (!activity) return []
88
92
  if (dimension.value === 'workspace') return activity.byWorkspace
89
93
  if (dimension.value === 'service') return activity.byService
94
+ if (dimension.value === 'repo') return activity.byRepo
90
95
  return activity.byTaskType
91
96
  })
92
97
 
98
+ /**
99
+ * What a given breakdown left out, or null when it is complete. Only the activity-scaled
100
+ * dimensions are ever capped, so this is null for everything else and the card renders no
101
+ * footer at all.
102
+ */
103
+ function capFor(dimension: ReportSpendDimension) {
104
+ return view.value?.capped.find((cap) => cap.dimension === dimension) ?? null
105
+ }
106
+
93
107
  const DAY_MS = 24 * 60 * 60 * 1000
94
108
 
95
109
  // How the window's SPEND half was answered. The long (TCO) windows read the durable
@@ -408,22 +422,12 @@ watch(
408
422
  </section>
409
423
  </div>
410
424
 
411
- <!-- The TCO axes: what a repository, a ticket and a single run actually cost.
412
- Spend-only, like the pair above, because a run's activity is already sliced by
413
- the service that owns the repo and there is no second population to pair a
414
- ticket with, and a run IS the unit activity counts. -->
415
- <div class="grid gap-6 md:grid-cols-3">
416
- <section>
417
- <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
418
- {{ t('reports.spend.byRepo') }}
419
- </h2>
420
- <ReportsSpendBreakdown
421
- :rows="view.spend.byRepo"
422
- :currency="currency"
423
- test-id="reports-spend-repo"
424
- :label-of="sliceLabel"
425
- />
426
- </section>
425
+ <!-- The two spend-only TCO axes: what a ticket and a single run cost. There is no
426
+ second population to pair a ticket with, and a run IS the unit activity counts.
427
+ The repository axis has both halves, so it sits in the paired switch below.
428
+ Both of these grow with ACTIVITY rather than with a catalog, so both are the
429
+ breakdowns the projection caps, and each says so under its own card. -->
430
+ <div class="grid gap-6 md:grid-cols-2">
427
431
  <section>
428
432
  <h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">
429
433
  {{ t('reports.spend.byTicket') }}
@@ -433,6 +437,7 @@ watch(
433
437
  :currency="currency"
434
438
  test-id="reports-spend-ticket"
435
439
  :label-of="sliceLabel"
440
+ :cap="capFor('ticket')"
436
441
  />
437
442
  </section>
438
443
  <section>
@@ -444,6 +449,7 @@ watch(
444
449
  :currency="currency"
445
450
  test-id="reports-spend-run"
446
451
  :label-of="sliceLabel"
452
+ :cap="capFor('run')"
447
453
  />
448
454
  </section>
449
455
  </div>
@@ -1,13 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
- import type { ReportSpendRow } from '~/types/execution'
3
+ import type { ReportSpendCap, ReportSpendRow } from '~/types/execution'
4
4
  import { maxOf, segmentPct, spendMagnitude } from './ReportsPanel.logic'
5
5
 
6
6
  // One ranked spend breakdown: a horizontal bar per slice, split into the metered
7
7
  // (`violet-500`, real money) and subscription (`amber-600`, illustrative equivalent-API
8
8
  // cost) segments with a surface gap between them. Extracted from `ReportsPanel.vue` because
9
- // the panel renders four of these against different dimensions the shape is identical,
10
- // only the row list and the heading differ.
9
+ // the panel renders five of these against different dimensions: the shape is identical, only
10
+ // the row list and the heading differ.
11
11
  //
12
12
  // Every bar is scaled against the HEAVIEST slice in this list, so a full bar means "the
13
13
  // biggest consumer here", never an absolute budget. The panel owns the legend (the two
@@ -18,6 +18,13 @@ const props = defineProps<{
18
18
  testId: string
19
19
  /** Resolves a slice's display name (the panel owns the unattributed/i18n vocabulary). */
20
20
  labelOf: (row: ReportSpendRow) => string
21
+ /**
22
+ * What this breakdown left out, when the projection capped it. Rendered as a footer note:
23
+ * a reader who assumes a list is complete would read the heaviest hundred repositories as
24
+ * the whole bill, so the tail is STATED rather than left to be inferred from the row count.
25
+ * The window totals above still cover it, which is what the note says.
26
+ */
27
+ cap?: ReportSpendCap | null
21
28
  }>()
22
29
 
23
30
  const { t, n } = useI18n()
@@ -70,5 +77,10 @@ const max = computed(() => maxOf(props.rows, spendMagnitude))
70
77
  </p>
71
78
  </li>
72
79
  </ul>
80
+ <p v-if="cap" class="mt-3 text-[10px] text-slate-500" :data-testid="`${testId}-capped`">
81
+ {{
82
+ t('reports.spend.capped', { shown: n(cap.returned), omitted: n(cap.omitted) }, cap.omitted)
83
+ }}
84
+ </p>
73
85
  </div>
74
86
  </template>
@@ -0,0 +1,193 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { effectScope, nextTick, ref, type EffectScope } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+ import { useBoardStore } from '~/stores/board'
5
+ import { useBlockDrag } from '~/composables/useBlockDrag'
6
+ import { useFrameOverlapGuard } from '~/composables/useFrameOverlapGuard'
7
+ import { EMPTY_FRAME_SIZE, FRAME_GAP, framesCollide } from '~/utils/framePlacement'
8
+
9
+ /**
10
+ * The guard is the wiring around `resolveFrameOverlaps` (whose geometry is pinned in
11
+ * `framePlacement.spec.ts`). What is worth testing here is not the bouncing but the two policies
12
+ * layered over it, because both are invisible in the geometry and both were wrong once:
13
+ *
14
+ * - WHEN it corrects: never against the geometry of a gesture still under the pointer, because
15
+ * those corrections displace frames the user is only dragging past.
16
+ * - WHO writes the correction back: only the client whose own gesture caused it, and only through
17
+ * `moveBlock`, whose rollback needs to find the position the server actually holds.
18
+ */
19
+ const canWriteBoard = ref(true)
20
+
21
+ function frame(id: string, x: number, y: number): Block {
22
+ return {
23
+ id,
24
+ title: id,
25
+ type: 'service',
26
+ description: '',
27
+ position: { x, y },
28
+ status: 'planned',
29
+ progress: 0,
30
+ dependsOn: [],
31
+ executionId: null,
32
+ level: 'frame',
33
+ parentId: null,
34
+ }
35
+ }
36
+
37
+ interface Write {
38
+ id: string
39
+ to: { x: number; y: number }
40
+ /** What the STORE still held when the write was asked for: the value a rollback restores. */
41
+ storedAtCall: { x: number; y: number }
42
+ }
43
+
44
+ describe('useFrameOverlapGuard', () => {
45
+ let scope: EffectScope
46
+ let board: ReturnType<typeof useBoardStore>
47
+ let writes: Write[]
48
+
49
+ beforeEach(() => {
50
+ canWriteBoard.value = true
51
+ vi.stubGlobal('useUiStore', () => ({ zoom: 1 }))
52
+ vi.stubGlobal('useWorkspaceAccess', () => ({ canWriteBoard }))
53
+ board = useBoardStore()
54
+ writes = []
55
+ // Stands in for the real `moveBlock`, which records nothing but does apply its position
56
+ // optimistically before the round-trip, which is the behaviour the guard leans on to correct the view
57
+ // and write it in one act. The snapshot is taken BEFORE that, so the rollback assertion below
58
+ // reads the position the real one would restore to.
59
+ board.moveBlock = ((id: string, to: { x: number; y: number }) => {
60
+ writes.push({ id, to, storedAtCall: { ...board.getBlock(id)!.position } })
61
+ board.previewMove(id, to)
62
+ return Promise.resolve()
63
+ }) as unknown as typeof board.moveBlock
64
+ // The composables under test reach the store through Nuxt's auto-import, which plain Vitest
65
+ // does not provide; hand them the same instance the assertions read.
66
+ vi.stubGlobal('useBoardStore', () => board)
67
+ scope = effectScope()
68
+ })
69
+
70
+ afterEach(() => scope.stop())
71
+
72
+ /** Start the guard over the frames the board currently holds and let its first pass run. */
73
+ async function guard() {
74
+ scope.run(() => useFrameOverlapGuard())
75
+ await nextTick()
76
+ }
77
+
78
+ const rectOf = (id: string) => ({ ...board.getBlock(id)!.position, ...EMPTY_FRAME_SIZE })
79
+ const written = () => writes.map((w) => w.id)
80
+
81
+ it('draws an overlapping board clear without writing anything back', async () => {
82
+ board.hydrate([frame('a', 0, 0), frame('b', 100, 0)])
83
+ await guard()
84
+
85
+ expect(framesCollide(rectOf('a'), rectOf('b'), FRAME_GAP)).toBe(false)
86
+ // Nobody authored this overlap locally, so nobody writes it: every client draws the same
87
+ // correction from the same rects, and one write per open session would be pure amplification.
88
+ expect(written()).toEqual([])
89
+ })
90
+
91
+ it('leaves a board whose frames already clear each other alone', async () => {
92
+ board.hydrate([frame('a', 0, 0), frame('b', EMPTY_FRAME_SIZE.w + FRAME_GAP, 0)])
93
+ await guard()
94
+
95
+ expect(board.getBlock('b')!.position).toEqual({ x: EMPTY_FRAME_SIZE.w + FRAME_GAP, y: 0 })
96
+ expect(written()).toEqual([])
97
+ })
98
+
99
+ it('separates a frame that grew into its neighbour, keeping the grown frame in place', async () => {
100
+ // Two empty services a gap apart, then the first one gains a task: it stops rendering the
101
+ // "add the first task" panel and grows to the lane footprint, over its neighbour.
102
+ board.hydrate([frame('a', 0, 0), frame('b', EMPTY_FRAME_SIZE.w + FRAME_GAP, 0)])
103
+ await guard()
104
+
105
+ board.upsert({ ...frame('t1', 0, 0), level: 'task', parentId: 'a' })
106
+ await nextTick()
107
+
108
+ expect(board.getBlock('a')!.position).toEqual({ x: 0, y: 0 })
109
+ expect(
110
+ framesCollide(
111
+ { ...board.getBlock('a')!.position, ...board.containerSize('a') },
112
+ rectOf('b'),
113
+ FRAME_GAP,
114
+ ),
115
+ ).toBe(false)
116
+ expect(written()).toEqual([])
117
+ })
118
+
119
+ it('leaves the frames a drag passes over alone, and settles once the pointer is released', async () => {
120
+ board.hydrate([frame('a', 0, 0), frame('b', 600, 0), frame('c', 1200, 0)])
121
+ await guard()
122
+
123
+ // Stand in for a header-handle drag: the dragged frame's position is previewed on every
124
+ // pointer move, and `draggingId` marks it as the one the guard must not move.
125
+ const { draggingId } = useBlockDrag()
126
+ draggingId.value = 'a'
127
+ // Dragged ACROSS `b` on the way to its resting place beside `c`. Bouncing `b` here would
128
+ // persist a rearrangement of a service the user never touched.
129
+ board.previewMove('a', { x: 600, y: 0 })
130
+ await nextTick()
131
+ expect(board.getBlock('b')!.position).toEqual({ x: 600, y: 0 })
132
+ expect(written()).toEqual([])
133
+
134
+ board.previewMove('a', { x: 1180, y: 0 })
135
+ await nextTick()
136
+ expect(board.getBlock('b')!.position).toEqual({ x: 600, y: 0 })
137
+
138
+ // Release: the drop commits `a` itself, and the guard settles the board around it.
139
+ draggingId.value = null
140
+ await nextTick()
141
+
142
+ expect(board.getBlock('a')!.position).toEqual({ x: 1180, y: 0 })
143
+ expect(written()).toEqual(['c'])
144
+ expect(framesCollide(rectOf('a'), rectOf('c'), FRAME_GAP)).toBe(false)
145
+ })
146
+
147
+ it('writes through moveBlock alone, so a refused correction can still roll back', async () => {
148
+ board.hydrate([frame('a', 0, 0), frame('b', 600, 0)])
149
+ await guard()
150
+
151
+ const { draggingId } = useBlockDrag()
152
+ draggingId.value = 'a'
153
+ board.previewMove('a', { x: 580, y: 0 })
154
+ await nextTick()
155
+ draggingId.value = null
156
+ await nextTick()
157
+
158
+ // `moveBlock` snapshots the position it finds and restores it if the write is refused. A
159
+ // `previewMove` ahead of it would hand it the CORRECTION as the value to roll back to,
160
+ // leaving the board showing a position the server never stored.
161
+ expect(writes).toHaveLength(1)
162
+ expect(writes[0]!.storedAtCall).toEqual({ x: 600, y: 0 })
163
+ expect(writes[0]!.to).not.toEqual(writes[0]!.storedAtCall)
164
+ })
165
+
166
+ it('writes nothing for a drag that was cancelled rather than dropped', async () => {
167
+ board.hydrate([frame('a', 0, 0), frame('b', 600, 0)])
168
+ await guard()
169
+
170
+ const { draggingId } = useBlockDrag()
171
+ draggingId.value = 'a'
172
+ board.previewMove('a', { x: 580, y: 0 })
173
+ await nextTick()
174
+ // A `pointercancel` (or the dragged component unmounting) puts the frame back and commits
175
+ // nothing. The board it leaves behind is already clear, so settling finds no work: the
176
+ // neighbour displacement a cancelled gesture caused must not outlive it.
177
+ board.previewMove('a', { x: 0, y: 0 })
178
+ draggingId.value = null
179
+ await nextTick()
180
+
181
+ expect(board.getBlock('b')!.position).toEqual({ x: 600, y: 0 })
182
+ expect(written()).toEqual([])
183
+ })
184
+
185
+ it('corrects the view for a read-only viewer but writes nothing', async () => {
186
+ canWriteBoard.value = false
187
+ board.hydrate([frame('a', 0, 0), frame('b', 100, 0)])
188
+ await guard()
189
+
190
+ expect(framesCollide(rectOf('a'), rectOf('b'), FRAME_GAP)).toBe(false)
191
+ expect(written()).toEqual([])
192
+ })
193
+ })
@@ -0,0 +1,145 @@
1
+ import { computed, watch } from 'vue'
2
+ import { useBlockDrag } from '~/composables/useBlockDrag'
3
+ import { useFrameResize } from '~/composables/useFrameResize'
4
+ import { EPIC_NODE_SIZE, resolveFrameOverlaps, type PlacedRect } from '~/utils/framePlacement'
5
+
6
+ /**
7
+ * The board's standing "no two top-level nodes overlap" invariant.
8
+ *
9
+ * Frames used to be rendered exactly where they were stored and allowed to overlap freely, with
10
+ * hover-driven stacking as the way to reach the one underneath. That only ever worked for the
11
+ * frame you were pointing at: everything under a neighbour was invisible, and nothing on the
12
+ * board said it was there. Placement already refused to CREATE an overlap
13
+ * (`findFreeFramePosition`), but three later events could still make one (dragging a frame onto
14
+ * a neighbour, dragging a border out into one, and a frame growing when its first task arrives,
15
+ * since an empty service reserves a much smaller footprint than one rendering lanes), so the rule
16
+ * was true of a board nobody had touched and of no other.
17
+ *
18
+ * This closes it as an invariant rather than as a check on each of those writes: it watches the
19
+ * rendered geometry of every top-level board node and, whenever two come to overlap, bounces them
20
+ * apart (`resolveFrameOverlaps`). Being cause-agnostic is the point: a future write that moves or
21
+ * grows a frame is covered without knowing this exists.
22
+ *
23
+ * ## Correcting the view and writing the correction are two different jobs
24
+ *
25
+ * They have opposite requirements, and the guard keeps them apart, because conflating them is
26
+ * what makes a layout fixer fight itself across clients:
27
+ *
28
+ * - **Every client corrects what it DRAWS, always.** `resolveFrameOverlaps` is a pure function of
29
+ * the rects, so this needs no coordination: two browsers holding one board draw it the same way
30
+ * whatever order their events arrived in. A read-only viewer gets the corrected view for free,
31
+ * which is the point (the invariant is about what is VISIBLE).
32
+ * - **Exactly one client may WRITE a correction, and only a local GESTURE elects it.** A drag or
33
+ * a border resize has an unambiguous single author: the browser the pointer is in. That client
34
+ * settles the board when the gesture ends and persists the neighbours it displaced.
35
+ *
36
+ * So a correction is persisted only where the local user caused it. That is a deliberate limit,
37
+ * not an oversight, and the third cause above is what it costs: a frame that grows on its own has
38
+ * NO author (the task may have arrived from a pipeline, with no browser involved), so its
39
+ * correction is DRAWN everywhere and WRITTEN by nobody. The stored position stays the one someone
40
+ * chose, and the board derives a clear presentation from it, this session and the next.
41
+ *
42
+ * The alternative is worse in both directions. Having every writer-capable client persist what it
43
+ * resolved costs a write and a board-wide event per open session for one overlap, and the first of
44
+ * those writes moves the geometry the others are still resolving. And a persisted correction is
45
+ * not even the better answer where it lands: a frame that grew for its first task shrinks again
46
+ * when that task is deleted, and a neighbour bounced by a WRITE stays where it was pushed, while a
47
+ * neighbour bounced by a projection is recomputed off the server's own geometry on the next
48
+ * hydrate and simply comes back.
49
+ *
50
+ * ## Why it runs in the SPA
51
+ *
52
+ * A frame's footprint is derived from the lane geometry the browser renders it at
53
+ * (`containerSize`), which the server cannot compute: it stores a position and, at most, a size
54
+ * override. So the only layer that can tell whether two frames overlap is the one drawing them.
55
+ *
56
+ * Epics take part as well as frames. They are top-level nodes on the same canvas (placement
57
+ * already reserves space around them), so an epic card parked over a frame hides exactly as much
58
+ * of it as another frame would.
59
+ */
60
+ export function useFrameOverlapGuard() {
61
+ const board = useBoardStore()
62
+ const access = useWorkspaceAccess()
63
+ const { draggingId } = useBlockDrag()
64
+ const { resizingId } = useFrameResize()
65
+
66
+ /** Every top-level node's rendered rect, in the flow-space coordinates blocks are stored in. */
67
+ const nodeRects = computed<PlacedRect[]>(() => [
68
+ ...board.frames.map((f) => {
69
+ const size = board.containerSize(f.id)
70
+ return { id: f.id, x: f.position.x, y: f.position.y, w: size.w, h: size.h }
71
+ }),
72
+ ...board.epics.map((e) => ({
73
+ id: e.id,
74
+ x: e.position.x,
75
+ y: e.position.y,
76
+ ...EPIC_NODE_SIZE,
77
+ })),
78
+ ])
79
+
80
+ /** The node the local pointer is placing right now, if any. */
81
+ const gestureId = computed(() => draggingId.value ?? resizingId.value)
82
+
83
+ /**
84
+ * Bounce the overlapping nodes apart, holding `anchorId` still.
85
+ *
86
+ * `persist` picks the channel, and the two are mutually exclusive on purpose.
87
+ * {@link useBoardStore.moveBlock} ALREADY applies its position optimistically and restores the
88
+ * previous one if the write is refused, so it is both the local correction and the write. A
89
+ * `previewMove` ahead of it would break exactly that: `moveBlock` snapshots the position it
90
+ * finds, so a rollback would restore the correction rather than undo it, leaving the SPA
91
+ * showing a position the server never stored.
92
+ */
93
+ function bounce(anchorId: string | null, persist: boolean) {
94
+ for (const [id, position] of resolveFrameOverlaps(nodeRects.value, { anchorId })) {
95
+ if (persist) void board.moveBlock(id, position)
96
+ else board.previewMove(id, position)
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Draw the board clear, on every client, writing nothing.
102
+ *
103
+ * Held for the duration of a gesture. A drag previews a new position on every pointer move, and
104
+ * bouncing neighbours off those in-flight positions displaces frames the user is only passing
105
+ * OVER: each pass reads the neighbour at the spot the previous pass pushed it to, so the
106
+ * displacement accumulates instead of springing back, and a drag across a populated board
107
+ * rearranges services nobody touched. A frame drawn on top of its neighbours while the pointer
108
+ * is holding it is what direct manipulation looks like; the board settles when it is let go.
109
+ *
110
+ * Anchorless, because a change with no local gesture behind it has no frame the user is
111
+ * placing: it is a growth, or another client's write arriving. Reading order decides, and it is
112
+ * the same reading order everywhere.
113
+ */
114
+ function project() {
115
+ if (gestureId.value) return
116
+ bounce(null, false)
117
+ }
118
+
119
+ // Default `pre` flush: the correction is applied before the board re-renders, so an overlap
120
+ // never reaches the screen even for one frame. Re-entrant by construction (correcting a
121
+ // position invalidates `nodeRects`), but `resolveFrameOverlaps` is idempotent, so the second
122
+ // pass finds a settled board and stops there.
123
+ watch(nodeRects, project, { immediate: true })
124
+
125
+ /**
126
+ * Settle the board around the node the local user just placed, and persist what moved.
127
+ *
128
+ * `post` flush so the gesture's own commit has landed first: a drop calls `moveBlock` (and a
129
+ * released border `resizeBlock`) before clearing the id, and both apply their geometry
130
+ * optimistically, so by the time this runs `nodeRects` already holds the placed node's final
131
+ * footprint. The resolution is therefore computed from live geometry rather than replayed from
132
+ * anything the gesture accumulated, which is what makes every way a gesture can end correct
133
+ * without a case for each: a `pointercancel` (or the dragged component unmounting) restores the
134
+ * pre-drag position, so this finds a clear board and writes nothing at all.
135
+ */
136
+ watch(
137
+ gestureId,
138
+ (now, before) => {
139
+ if (now || !before) return
140
+ // Access can be revoked mid-session; a viewer still gets the corrected view.
141
+ bounce(before, access.canWriteBoard.value)
142
+ },
143
+ { flush: 'post' },
144
+ )
145
+ }
@@ -22,6 +22,16 @@ const HANDLES = {
22
22
 
23
23
  export type ResizeEdge = keyof typeof HANDLES
24
24
 
25
+ /**
26
+ * Id of the container currently being resized, for cursor/grip styling and for the board's
27
+ * overlap guard, which stands down while a border is still under the pointer and settles the
28
+ * board around this container once it is released.
29
+ *
30
+ * Module-level for the same reason as `useBlockDrag`'s `draggingId`: only one container is ever
31
+ * resized at a time, and the grips that start the drag are not the only reader.
32
+ */
33
+ const resizingId = ref<string | null>(null)
34
+
25
35
  /** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
26
36
  export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
27
37
 
@@ -51,8 +61,6 @@ export function useFrameResize() {
51
61
  const board = useBoardStore()
52
62
  const ui = useUiStore()
53
63
  const access = useWorkspaceAccess()
54
- /** Id of the container currently being resized, for cursor/grip styling. */
55
- const resizingId = ref<string | null>(null)
56
64
 
57
65
  /**
58
66
  * How far the origin may travel INWARD before the nearest child would land at a negative
@@ -136,10 +144,15 @@ export function useFrameResize() {
136
144
  window.removeEventListener('pointercancel', onUp)
137
145
  body.style.cursor = priorCursor
138
146
  body.style.userSelect = priorUserSelect
139
- resizingId.value = null
140
147
  // A press with no movement is not a resize: committing it would emit a coarse board signal
141
148
  // (every other client re-hydrates) to store the geometry it already had.
149
+ //
150
+ // Committed BEFORE the id is released, matching `useBlockDrag`'s drop: `resizeBlock` applies
151
+ // the final bounds optimistically, and the overlap guard settles the board on the release of
152
+ // this id, so clearing it first would have the guard read the geometry of a resize that had
153
+ // not landed yet.
142
154
  if (moved) void board.resizeBlock(block.id, bounds, from)
155
+ resizingId.value = null
143
156
  }
144
157
  window.addEventListener('pointermove', onMove)
145
158
  window.addEventListener('pointerup', onUp)
@@ -1,10 +1,12 @@
1
1
  import { ref } from 'vue'
2
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.
3
+ // Service frames are kept clear of one another once settled (see useFrameOverlapGuard), but their
4
+ // boxes are not all there is to stack: chrome hangs outside the box, the guard stands down for the
5
+ // whole of a drag so a dragged frame crosses its neighbours freely, and Vue Flow needs SOME order
6
+ // regardless. The frame the pointer is over
7
+ // is, by definition, the un-obscured one at that point (pointerenter fires on the topmost
8
+ // element), so we track it and lift it above its neighbours. Module-level singleton: BlockNode
9
+ // sets it on hover, BoardCanvas reads it to set the Vue Flow node's z-index.
8
10
  const hoveredFrameId = ref<string | null>(null)
9
11
 
10
12
  export function useFrameStacking() {
@@ -4,9 +4,9 @@ import type { ReportWindow, ReportsView } from '~/types/execution'
4
4
  import { useAccountsStore } from '~/stores/accounts'
5
5
 
6
6
  /**
7
- * Reports: cross-cutting usage analytics for the active account spend per model and
8
- * agent kind, spend + run activity per workspace / service / task type, and a spend trend,
9
- * over a selectable window and optionally narrowed to one board.
7
+ * Reports: cross-cutting usage analytics for the active account: spend per model, agent
8
+ * kind, ticket and run, spend + run activity per workspace / service / repository / task
9
+ * type, and a spend trend, over a selectable window and optionally narrowed to one board.
10
10
  *
11
11
  * The sibling of the `platformObservability` store: same account scope, same admin gate,
12
12
  * same on-demand load. Nothing is pushed live (these are periodic rollups); changing the
@@ -43,6 +43,7 @@ export type {
43
43
  ReportSpendDimension,
44
44
  ReportActivityDimension,
45
45
  ReportSpendRow,
46
+ ReportSpendCap,
46
47
  ReportActivityRow,
47
48
  ReportTrendPoint,
48
49
  ReportTotals,
@@ -1,5 +1,12 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { findFreeFramePosition, framesCollide, FRAME_GAP, type FrameRect } from './framePlacement'
2
+ import {
3
+ findFreeFramePosition,
4
+ framesCollide,
5
+ resolveFrameOverlaps,
6
+ FRAME_GAP,
7
+ type FrameRect,
8
+ type PlacedRect,
9
+ } from './framePlacement'
3
10
 
4
11
  const size = { w: 360, h: 220 }
5
12
 
@@ -71,3 +78,133 @@ describe('findFreeFramePosition', () => {
71
78
  expect(dist).toBeGreaterThan(0)
72
79
  })
73
80
  })
81
+
82
+ /** Every pair on the resolved board clears the gap: the invariant the guard exists to hold. */
83
+ function assertNoOverlap(rects: PlacedRect[], moved: Map<string, { x: number; y: number }>) {
84
+ const settled = rects.map((r) => ({ ...r, ...moved.get(r.id) }))
85
+ for (const [i, a] of settled.entries()) {
86
+ for (const b of settled.slice(i + 1)) {
87
+ expect({ pair: [a.id, b.id], collides: framesCollide(a, b, FRAME_GAP) }).toEqual({
88
+ pair: [a.id, b.id],
89
+ collides: false,
90
+ })
91
+ }
92
+ }
93
+ }
94
+
95
+ describe('resolveFrameOverlaps', () => {
96
+ it('leaves a board whose frames already clear each other untouched', () => {
97
+ const rects: PlacedRect[] = [
98
+ { id: 'a', x: 0, y: 0, ...size },
99
+ { id: 'b', x: size.w + FRAME_GAP, y: 0, ...size },
100
+ ]
101
+ expect(resolveFrameOverlaps(rects)).toEqual(new Map())
102
+ })
103
+
104
+ it('bounces the neighbour off an anchored frame dropped on top of it, keeping the drop', () => {
105
+ const rects: PlacedRect[] = [
106
+ { id: 'dropped', x: 40, y: 0, ...size },
107
+ { id: 'sitting', x: 0, y: 0, ...size },
108
+ ]
109
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'dropped' })
110
+ // The frame the user placed keeps the exact spot they aimed at.
111
+ expect(moved.has('dropped')).toBe(false)
112
+ expect(moved.has('sitting')).toBe(true)
113
+ assertNoOverlap(rects, moved)
114
+ })
115
+
116
+ it('takes the shortest way out: a small overlap moves by a little, not a whole frame', () => {
117
+ const rects: PlacedRect[] = [
118
+ { id: 'anchor', x: 0, y: 0, ...size },
119
+ { id: 'nudged', x: size.w - 10, y: 0, ...size },
120
+ ]
121
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'anchor' })
122
+ // Clearing rightwards costs 10px of penetration plus the gap; going around would cost a
123
+ // whole frame width.
124
+ expect(moved.get('nudged')).toEqual({ x: size.w + FRAME_GAP, y: 0 })
125
+ })
126
+
127
+ it('separates a frame that grew into its neighbour, keeping the grown frame in place', () => {
128
+ // An empty service (360x220) with a neighbour placed a gap away, then its first task
129
+ // arrives and it grows to the lane footprint.
130
+ const grown = { w: 694, h: 486 }
131
+ const rects: PlacedRect[] = [
132
+ { id: 'grown', x: 0, y: 0, ...grown },
133
+ { id: 'neighbour', x: 360 + FRAME_GAP, y: 0, ...size },
134
+ ]
135
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'grown' })
136
+ expect(moved.has('grown')).toBe(false)
137
+ assertNoOverlap(rects, moved)
138
+ })
139
+
140
+ it('untangles a pile of frames stacked on one spot', () => {
141
+ const rects: PlacedRect[] = ['a', 'b', 'c', 'd', 'e'].map((id) => ({ id, x: 0, y: 0, ...size }))
142
+ assertNoOverlap(rects, resolveFrameOverlaps(rects))
143
+ })
144
+
145
+ it('answers the same way whatever order the rects arrive in', () => {
146
+ const rects: PlacedRect[] = [
147
+ { id: 'a', x: 0, y: 0, ...size },
148
+ { id: 'b', x: 30, y: 20, ...size },
149
+ { id: 'c', x: 60, y: 500, ...size },
150
+ { id: 'd', x: 10, y: 480, ...size },
151
+ ]
152
+ // Two clients hold the same board in whatever order their events arrived; a resolution that
153
+ // depended on that order would have them write different positions and fight.
154
+ const first = resolveFrameOverlaps(rects)
155
+ const second = resolveFrameOverlaps([...rects].reverse())
156
+ expect([...second].sort()).toEqual([...first].sort())
157
+ })
158
+
159
+ it('is idempotent: re-running over the resolved board moves nothing', () => {
160
+ const rects: PlacedRect[] = [
161
+ { id: 'a', x: 0, y: 0, ...size },
162
+ { id: 'b', x: 12, y: 8, ...size },
163
+ { id: 'c', x: 24, y: 16, ...size },
164
+ ]
165
+ const moved = resolveFrameOverlaps(rects)
166
+ const settled = rects.map((r) => ({ ...r, ...moved.get(r.id) }))
167
+ expect(resolveFrameOverlaps(settled)).toEqual(new Map())
168
+ })
169
+
170
+ it('clears fractional positions outright, so a pass cannot leave a sub-pixel to shave again', () => {
171
+ // A drag divides the pointer delta by the board zoom, so the stored positions really are
172
+ // fractional; rounding a correction the wrong way would leave a sliver of overlap behind.
173
+ const rects: PlacedRect[] = [
174
+ { id: 'a', x: 0.5, y: 0.25, ...size },
175
+ { id: 'b', x: 10.75, y: 0.5, ...size },
176
+ ]
177
+ const moved = resolveFrameOverlaps(rects, { anchorId: 'a' })
178
+ assertNoOverlap(rects, moved)
179
+ const settled = rects.map((r) => ({ ...r, ...moved.get(r.id) }))
180
+ expect(resolveFrameOverlaps(settled, { anchorId: 'a' })).toEqual(new Map())
181
+ })
182
+
183
+ it('settles the unanchored board in reading order: the top-left node keeps its place', () => {
184
+ // The policy with no anchor is POSITIONAL, not temporal. Whichever node is nearer the top of
185
+ // the board holds its spot and the one below it yields, regardless of which of the two arrived
186
+ // first: a block carries no shared creation stamp, so "the newcomer yields" is only knowable
187
+ // from a client's own history and two clients would answer it differently.
188
+ const rects: PlacedRect[] = [
189
+ { id: 'upper', x: 0, y: 480, ...size },
190
+ { id: 'lower', x: 0, y: 500, ...size },
191
+ ]
192
+ const moved = resolveFrameOverlaps(rects)
193
+ expect(moved.has('upper')).toBe(false)
194
+ expect(moved.has('lower')).toBe(true)
195
+ assertNoOverlap(rects, moved)
196
+ })
197
+
198
+ it('falls back to reading order when the anchor is not on the board', () => {
199
+ // The guard anchors on the id of whatever gesture just ended, which is routinely a task card
200
+ // rather than a top-level node. An anchor nothing matches must resolve exactly as none at all,
201
+ // or a task drag would settle the board differently from the growth it caused.
202
+ const rects: PlacedRect[] = [
203
+ { id: 'a', x: 0, y: 0, ...size },
204
+ { id: 'b', x: 40, y: 0, ...size },
205
+ ]
206
+ expect(resolveFrameOverlaps(rects, { anchorId: 'task-not-a-frame' })).toEqual(
207
+ resolveFrameOverlaps(rects),
208
+ )
209
+ })
210
+ })
@@ -106,3 +106,111 @@ export function findFreeFramePosition(
106
106
  const rightmost = existing.reduce((m, r) => Math.max(m, r.x + r.w), desired.x)
107
107
  return { x: rightmost + gap, y: desired.y }
108
108
  }
109
+
110
+ /** A rect on the board that knows which block it belongs to. */
111
+ export interface PlacedRect extends FrameRect {
112
+ readonly id: string
113
+ }
114
+
115
+ /**
116
+ * How many times one rect may be pushed off a neighbour before we stop nudging and fall back to
117
+ * the ring search. Each push clears the rect it hit but can walk it into a third one, so a dense
118
+ * cluster needs several; a cluster that needs more than this is one the incremental nudge is not
119
+ * going to untangle, and {@link findFreeFramePosition} always answers.
120
+ */
121
+ const MAX_SEPARATION_PUSHES = 8
122
+
123
+ /**
124
+ * The nearest place `moving` can sit that clears `settled` by `gap`: the minimum translation
125
+ * along whichever axis it is cheapest to leave by, which is what makes a frame nudged onto a
126
+ * neighbour bounce off the nearest border rather than teleport around it.
127
+ *
128
+ * Each candidate edge is rounded OUTWARD, away from the rect being cleared. A drag divides the
129
+ * pointer delta by the board zoom, so the positions coming in here are routinely fractional, and
130
+ * rounding the other way would leave a sub-pixel of the overlap behind for the next pass to find
131
+ * and shave again.
132
+ *
133
+ * Ties are broken in a fixed order (right before left, horizontal before vertical) rather than by
134
+ * anything read off the board, because every client resolves the same overlap independently and
135
+ * two of them answering differently would have the frames trade places on every refresh. A tie
136
+ * only arises when the two rects are exactly concentric on that axis.
137
+ */
138
+ function separatedPosition(moving: FrameRect, settled: FrameRect, gap: number): Point {
139
+ const right = Math.ceil(settled.x + settled.w + gap)
140
+ const left = Math.floor(settled.x - gap - moving.w)
141
+ const down = Math.ceil(settled.y + settled.h + gap)
142
+ const up = Math.floor(settled.y - gap - moving.h)
143
+ const x = Math.abs(right - moving.x) <= Math.abs(left - moving.x) ? right : left
144
+ const y = Math.abs(down - moving.y) <= Math.abs(up - moving.y) ? down : up
145
+ return Math.abs(x - moving.x) <= Math.abs(y - moving.y) ? { x, y: moving.y } : { x: moving.x, y }
146
+ }
147
+
148
+ /**
149
+ * Order the rects for settlement: `anchorId` first if it is on the board, then everything else in
150
+ * reading order (top row first, then left to right), with the id as the final tie-break.
151
+ *
152
+ * The order IS the policy. A rect settles into the space the ones before it have already taken, so
153
+ * whatever comes first keeps its exact position and later ones bounce off it. Naming the frame the
154
+ * local user is placing as the anchor is what makes a deliberate drop land where it was aimed
155
+ * while its neighbours move aside, instead of the other way round.
156
+ *
157
+ * There is at most ONE anchor, and that is deliberate rather than a simplification. A list would
158
+ * carry an order of its own, and the only orders available to build one from are per-client (the
159
+ * order a client's live events happened to arrive in, or a history only the client that watched
160
+ * the change has). Two clients ordering two anchors differently resolve the same overlap to
161
+ * different positions and then write over each other, which is the one failure mode a pure
162
+ * resolution exists to rule out. A single anchor has no order to disagree about.
163
+ *
164
+ * Everything else settles in READING ORDER, which is a POSITIONAL policy, not a temporal one: the
165
+ * node nearest the top-left of the board keeps its place and those below and to the right yield.
166
+ * It is worth being exact about this, because "a frame arriving on the board yields to the frames
167
+ * already there" is the rule one would reach for first and it is not implementable here. A block
168
+ * carries no shared creation or update stamp (see `blockSchema`), so arrival is only knowable from
169
+ * a client's own session history, which is precisely the per-client input ruled out above. Reading
170
+ * order is the strongest rule every client can agree on from the board alone.
171
+ */
172
+ function bySettlementOrder(anchorId: string | null | undefined) {
173
+ const priority = (r: PlacedRect) => (r.id === anchorId ? 0 : 1)
174
+ return (a: PlacedRect, b: PlacedRect): number =>
175
+ priority(a) - priority(b) || a.y - b.y || a.x - b.x || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
176
+ }
177
+
178
+ /**
179
+ * Push apart every board node that overlaps another, returning the new top-left of each one that
180
+ * had to move (and nothing for the ones that did not, so an already-clear board answers empty).
181
+ *
182
+ * This is the board's standing layout invariant, not a placement decision: a frame can come to
183
+ * overlap a neighbour long after it was placed, by being dragged onto it, by a border drag, or by
184
+ * growing when its first task arrives (an empty service reserves a much smaller footprint than
185
+ * one rendering lanes). All three land here, so the rule is stated once rather than at each of
186
+ * the writes that can break it.
187
+ *
188
+ * The result is a pure function of the rects and the single `anchorId`, with tie-breaks fixed in
189
+ * code rather than read off the board, so every client holding the same board computes the same
190
+ * answer whatever order its own events arrived in. That is what lets each of them correct what it
191
+ * DRAWS without any of them having to agree first; who may WRITE a correction back is a separate
192
+ * question, settled in {@link useFrameOverlapGuard}.
193
+ */
194
+ export function resolveFrameOverlaps(
195
+ rects: readonly PlacedRect[],
196
+ opts?: { anchorId?: string | null; gap?: number },
197
+ ): Map<string, Point> {
198
+ const gap = opts?.gap ?? FRAME_GAP
199
+ const settled: FrameRect[] = []
200
+ const moved = new Map<string, Point>()
201
+ for (const rect of [...rects].sort(bySettlementOrder(opts?.anchorId))) {
202
+ let at: FrameRect = { ...rect }
203
+ for (let push = 0; push < MAX_SEPARATION_PUSHES; push++) {
204
+ const blocker = settled.find((s) => framesCollide(at, s, gap))
205
+ if (!blocker) break
206
+ at = { ...at, ...separatedPosition(at, blocker, gap) }
207
+ }
208
+ if (!fits(at, settled, gap)) {
209
+ const free = findFreeFramePosition(settled, rect, { x: rect.x, y: rect.y }, gap)
210
+ at = { ...rect, x: free.x, y: free.y }
211
+ }
212
+ settled.push(at)
213
+ if (at.x !== rect.x || at.y !== rect.y) moved.set(rect.id, { x: at.x, y: at.y })
214
+ }
215
+ return moved
216
+ }
@@ -4022,6 +4022,7 @@
4022
4022
  "dimension": {
4023
4023
  "workspace": "Board",
4024
4024
  "service": "Service",
4025
+ "repo": "Repository",
4025
4026
  "taskType": "Aufgabentyp"
4026
4027
  },
4027
4028
  "breakdown": {
@@ -4048,14 +4049,14 @@
4048
4049
  "spend": {
4049
4050
  "byModel": "Kosten nach Modell",
4050
4051
  "byAgentKind": "Kosten nach Agententyp",
4051
- "byRepo": "Kosten nach Repository",
4052
4052
  "byTicket": "Kosten nach Ticket",
4053
4053
  "byRun": "Kosten nach Lauf",
4054
4054
  "heading": "Kosten",
4055
4055
  "empty": "In diesem Zeitraum wurde keine Nutzung erfasst.",
4056
4056
  "calls": "{count} Aufruf | {count} Aufrufe",
4057
4057
  "tokens": "{input} rein / {output} raus",
4058
- "subscriptionAside": "+{value} Abo"
4058
+ "subscriptionAside": "+{value} Abo",
4059
+ "capped": "Angezeigt werden die {shown} teuersten. {omitted} weiterer Eintrag ist nicht aufgeführt, in den Summen oben aber enthalten. | Angezeigt werden die {shown} teuersten. {omitted} weitere Einträge sind nicht aufgeführt, in den Summen oben aber enthalten."
4059
4060
  },
4060
4061
  "activity": {
4061
4062
  "heading": "Läufe",
@@ -2073,6 +2073,7 @@
2073
2073
  "dimension": {
2074
2074
  "workspace": "Board",
2075
2075
  "service": "Service",
2076
+ "repo": "Repository",
2076
2077
  "taskType": "Task type"
2077
2078
  },
2078
2079
  "breakdown": {
@@ -2099,14 +2100,14 @@
2099
2100
  "spend": {
2100
2101
  "byModel": "Spend by model",
2101
2102
  "byAgentKind": "Spend by agent kind",
2102
- "byRepo": "Spend by repository",
2103
2103
  "byTicket": "Spend by ticket",
2104
2104
  "byRun": "Spend by run",
2105
2105
  "heading": "Spend",
2106
2106
  "empty": "No recorded usage in this window.",
2107
2107
  "calls": "{count} call | {count} calls",
2108
2108
  "tokens": "{input} in / {output} out",
2109
- "subscriptionAside": "+{value} subscription"
2109
+ "subscriptionAside": "+{value} subscription",
2110
+ "capped": "Showing the {shown} costliest. {omitted} more is not listed, though the totals above still include it. | Showing the {shown} costliest. {omitted} more are not listed, though the totals above still include them."
2110
2111
  },
2111
2112
  "activity": {
2112
2113
  "heading": "Runs",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "Tablero",
1971
1971
  "service": "Servicio",
1972
+ "repo": "Repositorio",
1972
1973
  "taskType": "Tipo de tarea"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "Gasto por modelo",
1997
1998
  "byAgentKind": "Gasto por tipo de agente",
1998
- "byRepo": "Gasto por repositorio",
1999
1999
  "byTicket": "Gasto por tique",
2000
2000
  "byRun": "Gasto por ejecución",
2001
2001
  "heading": "Gasto",
2002
2002
  "empty": "No se registró uso en este periodo.",
2003
2003
  "calls": "{count} llamada | {count} llamadas",
2004
2004
  "tokens": "{input} de entrada / {output} de salida",
2005
- "subscriptionAside": "+{value} suscripción"
2005
+ "subscriptionAside": "+{value} suscripción",
2006
+ "capped": "Se muestran los {shown} más costosos. Hay {omitted} más que no aparece, aunque los totales de arriba sí lo incluyen. | Se muestran los {shown} más costosos. Hay {omitted} más que no aparecen, aunque los totales de arriba sí los incluyen."
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "Ejecuciones",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "Tableau",
1971
1971
  "service": "Service",
1972
+ "repo": "Dépôt",
1972
1973
  "taskType": "Type de tâche"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "Dépense par modèle",
1997
1998
  "byAgentKind": "Dépense par type d’agent",
1998
- "byRepo": "Dépense par dépôt",
1999
1999
  "byTicket": "Dépense par ticket",
2000
2000
  "byRun": "Dépense par exécution",
2001
2001
  "heading": "Dépense",
2002
2002
  "empty": "Aucune utilisation enregistrée sur cette période.",
2003
2003
  "calls": "{count} appel | {count} appels",
2004
2004
  "tokens": "{input} en entrée / {output} en sortie",
2005
- "subscriptionAside": "+{value} abonnement"
2005
+ "subscriptionAside": "+{value} abonnement",
2006
+ "capped": "Les {shown} plus coûteux sont affichés. {omitted} autre n’est pas listé, mais les totaux ci-dessus l’incluent. | Les {shown} plus coûteux sont affichés. {omitted} autres ne sont pas listés, mais les totaux ci-dessus les incluent."
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "Exécutions",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "לוח",
1971
1971
  "service": "שירות",
1972
+ "repo": "מאגר",
1972
1973
  "taskType": "סוג משימה"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "עלות לפי מודל",
1997
1998
  "byAgentKind": "עלות לפי סוג סוכן",
1998
- "byRepo": "עלות לפי מאגר",
1999
1999
  "byTicket": "עלות לפי כרטיס",
2000
2000
  "byRun": "עלות לפי הרצה",
2001
2001
  "heading": "עלות",
2002
2002
  "empty": "לא נרשם שימוש בטווח הזה.",
2003
2003
  "calls": "קריאה אחת | שתי קריאות | {count} קריאות",
2004
2004
  "tokens": "{input} נכנס / {output} יוצא",
2005
- "subscriptionAside": "+{value} מנוי"
2005
+ "subscriptionAside": "+{value} מנוי",
2006
+ "capped": "מוצגים {shown} היקרים ביותר. פריט אחד נוסף אינו מופיע ברשימה, אך הסיכומים שלמעלה כוללים גם אותו. | מוצגים {shown} היקרים ביותר. שני פריטים נוספים אינם מופיעים ברשימה, אך הסיכומים שלמעלה כוללים גם אותם. | מוצגים {shown} היקרים ביותר. {omitted} פריטים נוספים אינם מופיעים ברשימה, אך הסיכומים שלמעלה כוללים גם אותם."
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "ריצות",
@@ -4022,6 +4022,7 @@
4022
4022
  "dimension": {
4023
4023
  "workspace": "Board",
4024
4024
  "service": "Servizio",
4025
+ "repo": "Repository",
4025
4026
  "taskType": "Tipo di attività"
4026
4027
  },
4027
4028
  "breakdown": {
@@ -4048,14 +4049,14 @@
4048
4049
  "spend": {
4049
4050
  "byModel": "Spesa per modello",
4050
4051
  "byAgentKind": "Spesa per tipo di agente",
4051
- "byRepo": "Spesa per repository",
4052
4052
  "byTicket": "Spesa per ticket",
4053
4053
  "byRun": "Spesa per esecuzione",
4054
4054
  "heading": "Spesa",
4055
4055
  "empty": "Nessun utilizzo registrato in questo periodo.",
4056
4056
  "calls": "{count} chiamata | {count} chiamate",
4057
4057
  "tokens": "{input} in ingresso / {output} in uscita",
4058
- "subscriptionAside": "+{value} abbonamento"
4058
+ "subscriptionAside": "+{value} abbonamento",
4059
+ "capped": "Vengono mostrati i {shown} più costosi. Ne resta {omitted} non elencato, ma i totali qui sopra lo includono. | Vengono mostrati i {shown} più costosi. Ne restano altri {omitted} non elencati, ma i totali qui sopra li includono."
4059
4060
  },
4060
4061
  "activity": {
4061
4062
  "heading": "Esecuzioni",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "ボード",
1971
1971
  "service": "サービス",
1972
+ "repo": "リポジトリ",
1972
1973
  "taskType": "タスクの種類"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "モデル別の費用",
1997
1998
  "byAgentKind": "エージェント種別の費用",
1998
- "byRepo": "リポジトリ別の費用",
1999
1999
  "byTicket": "チケット別の費用",
2000
2000
  "byRun": "実行別の費用",
2001
2001
  "heading": "費用",
2002
2002
  "empty": "この期間に記録された利用はありません。",
2003
2003
  "calls": "{count} 件の呼び出し | {count} 件の呼び出し",
2004
2004
  "tokens": "入力 {input} / 出力 {output}",
2005
- "subscriptionAside": "+{value} サブスクリプション"
2005
+ "subscriptionAside": "+{value} サブスクリプション",
2006
+ "capped": "費用の大きい上位 {shown} 件を表示しています。ほかに {omitted} 件は一覧にありませんが、上の合計には含まれています。 | 費用の大きい上位 {shown} 件を表示しています。ほかに {omitted} 件は一覧にありませんが、上の合計には含まれています。"
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "実行",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "Tablica",
1971
1971
  "service": "Usługa",
1972
+ "repo": "Repozytorium",
1972
1973
  "taskType": "Typ zadania"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "Koszty według modelu",
1997
1998
  "byAgentKind": "Koszty według typu agenta",
1998
- "byRepo": "Koszty według repozytorium",
1999
1999
  "byTicket": "Koszty według zgłoszenia",
2000
2000
  "byRun": "Koszty według uruchomienia",
2001
2001
  "heading": "Koszty",
2002
2002
  "empty": "W tym okresie nie zarejestrowano użycia.",
2003
2003
  "calls": "{count} wywołanie | {count} wywołania | {count} wywołań",
2004
2004
  "tokens": "{input} wejścia / {output} wyjścia",
2005
- "subscriptionAside": "+{value} subskrypcja"
2005
+ "subscriptionAside": "+{value} subskrypcja",
2006
+ "capped": "Pokazano {shown} najdroższych. Kolejnej {omitted} pozycji nie ujęto na liście, ale sumy powyżej ją uwzględniają. | Pokazano {shown} najdroższych. Kolejnych {omitted} pozycji nie ujęto na liście, ale sumy powyżej je uwzględniają. | Pokazano {shown} najdroższych. Kolejnych {omitted} pozycji nie ujęto na liście, ale sumy powyżej je uwzględniają."
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "Uruchomienia",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "Pano",
1971
1971
  "service": "Servis",
1972
+ "repo": "Depo",
1972
1973
  "taskType": "Görev türü"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "Modele göre harcama",
1997
1998
  "byAgentKind": "Ajan türüne göre harcama",
1998
- "byRepo": "Depoya göre harcama",
1999
1999
  "byTicket": "Bilete göre harcama",
2000
2000
  "byRun": "Çalıştırmaya göre harcama",
2001
2001
  "heading": "Harcama",
2002
2002
  "empty": "Bu dönemde kayıtlı kullanım yok.",
2003
2003
  "calls": "{count} çağrı | {count} çağrı",
2004
2004
  "tokens": "{input} giriş / {output} çıkış",
2005
- "subscriptionAside": "+{value} abonelik"
2005
+ "subscriptionAside": "+{value} abonelik",
2006
+ "capped": "En maliyetli {shown} tanesi gösteriliyor. {omitted} tanesi listelenmiyor, ancak yukarıdaki toplamlara dahil. | En maliyetli {shown} tanesi gösteriliyor. {omitted} tanesi listelenmiyor, ancak yukarıdaki toplamlara dahil."
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "Çalıştırmalar",
@@ -1969,6 +1969,7 @@
1969
1969
  "dimension": {
1970
1970
  "workspace": "Дошка",
1971
1971
  "service": "Сервіс",
1972
+ "repo": "Репозиторій",
1972
1973
  "taskType": "Тип завдання"
1973
1974
  },
1974
1975
  "breakdown": {
@@ -1995,14 +1996,14 @@
1995
1996
  "spend": {
1996
1997
  "byModel": "Витрати за моделлю",
1997
1998
  "byAgentKind": "Витрати за типом агента",
1998
- "byRepo": "Витрати за репозиторієм",
1999
1999
  "byTicket": "Витрати за тікетом",
2000
2000
  "byRun": "Витрати за запуском",
2001
2001
  "heading": "Витрати",
2002
2002
  "empty": "За цей період використання не зафіксовано.",
2003
2003
  "calls": "{count} виклик | {count} виклики | {count} викликів",
2004
2004
  "tokens": "{input} вхід / {output} вихід",
2005
- "subscriptionAside": "+{value} підписка"
2005
+ "subscriptionAside": "+{value} підписка",
2006
+ "capped": "Показано {shown} найдорожчих. Ще {omitted} позицію не наведено в списку, але підсумки вище її враховують. | Показано {shown} найдорожчих. Ще {omitted} позиції не наведено в списку, але підсумки вище їх ураховують. | Показано {shown} найдорожчих. Ще {omitted} позицій не наведено в списку, але підсумки вище їх ураховують."
2006
2007
  },
2007
2008
  "activity": {
2008
2009
  "heading": "Запуски",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.286.6",
3
+ "version": "0.288.0",
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",
@@ -18,7 +18,7 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@cat-factory/contracts": "0.333.0",
21
+ "@cat-factory/contracts": "0.334.0",
22
22
  "@modular-frontend/core": "0.6.0",
23
23
  "@modular-vue/core": "^1.5.0",
24
24
  "@modular-vue/journeys": "^1.4.0",