@cat-factory/app 0.319.1 → 0.320.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.
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { agentKindMeta } from '~/utils/catalog'
3
3
  import { formatDuration, useNowTick } from '~/composables/useStepTimer'
4
- import { useFramePlacement } from '~/composables/useFramePlacement'
4
+ import { useTaskReveal } from '~/composables/useTaskReveal'
5
5
  import { LANE_REASON_LABEL_KEYS } from '~/utils/swimlanes'
6
6
  import type { QueueEntry } from '~/utils/queueSections'
7
7
  import IconButton from '~/components/common/IconButton.vue'
@@ -25,8 +25,7 @@ const { t } = useI18n()
25
25
  const ui = useUiStore()
26
26
  const execution = useExecutionStore()
27
27
  const agentRuns = useAgentRunsStore()
28
- const views = useWorkViewStore()
29
- const { focusFrame } = useFramePlacement()
28
+ const { revealTask } = useTaskReveal()
30
29
  // A shared 30s clock, so "waited 4m" advances on a quiet board instead of freezing until some
31
30
  // unrelated event re-evaluates the card.
32
31
  const nowTick = useNowTick(30_000)
@@ -125,13 +124,10 @@ function openTask() {
125
124
  /**
126
125
  * Leave the queue for the canvas, with this task selected and its service in view. The canvas
127
126
  * kept its own camera while the queue covered it, so without the pan the task is usually off
128
- * screen. A task has no position of its own (it lives in its frame's swimlanes), so the camera
129
- * centres on the frame.
127
+ * screen.
130
128
  */
131
129
  function showOnBoard() {
132
- views.show('board')
133
- ui.select(props.entry.task.id)
134
- void focusFrame(props.entry.service?.id ?? props.entry.task.id)
130
+ revealTask(props.entry.task.id)
135
131
  }
136
132
  </script>
137
133
 
@@ -1,6 +1,7 @@
1
1
  import { nextTick } from 'vue'
2
2
  import { useVueFlow } from '@vue-flow/core'
3
3
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
4
+ import { LOD_MIN_ZOOM } from '~/composables/useSemanticZoom'
4
5
  import {
5
6
  EMPTY_FRAME_SIZE,
6
7
  EPIC_NODE_SIZE,
@@ -27,7 +28,8 @@ const MIN_FOCUS_ZOOM = 0.6
27
28
 
28
29
  export function useFramePlacement() {
29
30
  const board = useBoardStore()
30
- const { viewport, setCenter, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
31
+ const { viewport, setCenter, screenToFlowCoordinate, findNode, onNodesInitialized, onMoveStart } =
32
+ useVueFlow(BOARD_FLOW_ID)
31
33
 
32
34
  /**
33
35
  * Every existing top-level board node's rect in flow-space — both service frames
@@ -87,5 +89,83 @@ export function useFramePlacement() {
87
89
  setCenter(cx, cy, { zoom, duration: opts?.duration ?? 400 })
88
90
  }
89
91
 
90
- return { freeFramePosition, focusFrame }
92
+ /** Whether the canvas has drawn the frame, so there is a measured node to centre on. */
93
+ function isRendered(id: string): boolean {
94
+ const node = findNode(id)
95
+ return !!node && node.dimensions.width > 0
96
+ }
97
+
98
+ /**
99
+ * Resolves once the canvas has measured the frame's node. A deep link followed on a fresh load
100
+ * arrives before the board has measured its nodes, and centring on a frame with no size yet puts
101
+ * the camera on its corner, or nowhere.
102
+ */
103
+ async function whenRendered(id: string): Promise<void> {
104
+ if (isRendered(id)) return
105
+ await new Promise<void>((resolve) => {
106
+ const { off } = onNodesInitialized(() => {
107
+ if (!isRendered(id)) return
108
+ off()
109
+ resolve()
110
+ })
111
+ })
112
+ }
113
+
114
+ /**
115
+ * Centre the camera on a task card, zoomed in to the level where the board renders a task's
116
+ * pipeline steps (`steps`), once the canvas has drawn its frame. A closer zoom is left alone. A
117
+ * task has no position of its own (it lives in its frame's swimlanes), so its centre is read off
118
+ * the card the frame drew; with no card to read, the camera centres on the frame instead.
119
+ */
120
+ async function focusTaskWhenRendered(taskId: string, frameId: string): Promise<void> {
121
+ if (!board.getBlock(frameId)) return
122
+ await whenRendered(frameId)
123
+ await nextTick()
124
+ const frameSelector = `.vue-flow__node[data-id="${CSS.escape(frameId)}"]`
125
+ // The card inside the frame's own node, not another surface that names the same task.
126
+ const cardSelector = `${frameSelector} [data-block-id="${CSS.escape(taskId)}"]`
127
+ if (!document.querySelector(cardSelector)) {
128
+ await focusFrame(frameId)
129
+ return
130
+ }
131
+ const zoom = Math.max(viewport.value.zoom, LOD_MIN_ZOOM.steps)
132
+ await centreOn(cardSelector, zoom, 400)
133
+ keepCentred(frameSelector, cardSelector, zoom)
134
+ }
135
+
136
+ /**
137
+ * Keep the card centred while the board settles around it, until the person moves the camera.
138
+ * At the `steps` level a frame lays its swimlanes out again as each card renders its steps, so
139
+ * the card moves inside its frame after the camera has landed on it. Each change of the frame's
140
+ * or the card's size re-centres on it; the first pan or zoom the person makes ends the follow.
141
+ */
142
+ function keepCentred(frameSelector: string, cardSelector: string, zoom: number): void {
143
+ const frame = document.querySelector(frameSelector)
144
+ const card = document.querySelector(cardSelector)
145
+ if (!frame || !card) return
146
+ const observer = new ResizeObserver(() => void centreOn(cardSelector, zoom, 0))
147
+ observer.observe(frame)
148
+ observer.observe(card)
149
+ const { off } = onMoveStart(({ event }) => {
150
+ // A programmatic move (this follow's own) carries no input event; a person's does.
151
+ const byPerson = 'sourceEvent' in event ? event.sourceEvent != null : true
152
+ if (!byPerson) return
153
+ observer.disconnect()
154
+ off()
155
+ })
156
+ }
157
+
158
+ /** Centre the camera on an element of the board at `zoom`, resolving when the move lands. */
159
+ async function centreOn(selector: string, zoom: number, duration: number): Promise<void> {
160
+ const element = document.querySelector(selector)
161
+ if (!element) return
162
+ const rect = element.getBoundingClientRect()
163
+ const centre = screenToFlowCoordinate({
164
+ x: rect.left + rect.width / 2,
165
+ y: rect.top + rect.height / 2,
166
+ })
167
+ await setCenter(centre.x, centre.y, { zoom, duration })
168
+ }
169
+
170
+ return { freeFramePosition, focusFrame, focusTaskWhenRendered }
91
171
  }
@@ -1,6 +1,6 @@
1
1
  import { createPinia, setActivePinia } from 'pinia'
2
- import { beforeEach, describe, expect, it } from 'vitest'
3
- import { captureRunDeepLink } from './useRunDeepLink'
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { applyRunDeepLink, captureRunDeepLink, type PendingRunLink } from './useRunDeepLink'
4
4
 
5
5
  /** Point `window.location` + `history` at a URL the capture can read and rewrite. */
6
6
  function setUrl(search: string): void {
@@ -54,3 +54,61 @@ describe('captureRunDeepLink', () => {
54
54
  })
55
55
  })
56
56
  })
57
+
58
+ describe('applyRunDeepLink', () => {
59
+ function surfaces() {
60
+ return {
61
+ revealTask: vi.fn(),
62
+ openRun: vi.fn(),
63
+ openOutcome: vi.fn(),
64
+ openObservability: vi.fn(),
65
+ openTestEvidence: vi.fn(),
66
+ }
67
+ }
68
+
69
+ function link(view: string | null, blockId: string | null = 'blk_1'): PendingRunLink {
70
+ return { workspaceId: 'ws_1', blockId, runId: 'exec_1', view }
71
+ }
72
+
73
+ it('brings the task into view and opens its running pipeline when no view is named', () => {
74
+ const opened = surfaces()
75
+ applyRunDeepLink(link(null), opened)
76
+ expect(opened.revealTask).toHaveBeenCalledWith('blk_1')
77
+ expect(opened.openRun).toHaveBeenCalledWith('blk_1')
78
+ expect(opened.openObservability).not.toHaveBeenCalled()
79
+ })
80
+
81
+ it('opens the running pipeline for view=run, the same as no view', () => {
82
+ const opened = surfaces()
83
+ applyRunDeepLink(link('run'), opened)
84
+ expect(opened.openRun).toHaveBeenCalledWith('blk_1')
85
+ })
86
+
87
+ it.each([
88
+ ['observability', 'openObservability', ['exec_1']],
89
+ ['test-evidence', 'openTestEvidence', ['exec_1']],
90
+ ['outcome', 'openOutcome', ['exec_1', 'blk_1']],
91
+ ] as const)('opens %s for its run, beside the task brought into view', (view, surface, args) => {
92
+ const opened = surfaces()
93
+ applyRunDeepLink(link(view), opened)
94
+ expect(opened.revealTask).toHaveBeenCalledWith('blk_1')
95
+ expect(opened[surface]).toHaveBeenCalledWith(...args)
96
+ expect(opened.openRun).not.toHaveBeenCalled()
97
+ })
98
+
99
+ it('lands on the task and opens nothing for a view it does not know', () => {
100
+ const opened = surfaces()
101
+ applyRunDeepLink(link('not-a-view'), opened)
102
+ expect(opened.revealTask).toHaveBeenCalledWith('blk_1')
103
+ for (const open of [opened.openRun, opened.openOutcome, opened.openObservability]) {
104
+ expect(open).not.toHaveBeenCalled()
105
+ }
106
+ })
107
+
108
+ it('opens no focus view and moves no camera for a link naming no task', () => {
109
+ const opened = surfaces()
110
+ applyRunDeepLink(link(null, null), opened)
111
+ expect(opened.revealTask).not.toHaveBeenCalled()
112
+ expect(opened.openRun).not.toHaveBeenCalled()
113
+ })
114
+ })
@@ -1,6 +1,11 @@
1
1
  /**
2
2
  * Boot-time replay of a RUN deep link: `?ws=<id>&block=<id>&run=<id>&view=<panel>`.
3
3
  *
4
+ * The task the link names is brought into view (`useTaskReveal`), and `view` picks what opens on
5
+ * it: `run` (also what a link with no `view` gets) opens the task's focus view, where its pipeline
6
+ * is followed step by step; `outcome`, `observability` and `test-evidence` open those result
7
+ * windows for the run. An unknown view still lands on the right board and task.
8
+ *
4
9
  * The engine's PR verification report links each PR back into the run: to the observability
5
10
  * panel (Model activity / Provided context), and to the Tester's result window holding the
6
11
  * evidence its environment-lifecycle section lists. Both are built from the deployment's public
@@ -13,11 +18,12 @@
13
18
  * is deleted rather than kept alongside it.
14
19
  */
15
20
  import { watch } from 'vue'
21
+ import { useTaskReveal } from './useTaskReveal'
16
22
  import { useUiStore } from '../stores/ui'
17
23
  import { useWorkspaceStore } from '../stores/workspace'
18
24
 
19
25
  /** The parsed link, or null when the URL carried none. */
20
- interface PendingRunLink {
26
+ export interface PendingRunLink {
21
27
  workspaceId: string | null
22
28
  blockId: string | null
23
29
  runId: string
@@ -50,13 +56,50 @@ export function captureRunDeepLink(): PendingRunLink | null {
50
56
  return link
51
57
  }
52
58
 
59
+ /** The view a link with no `view` opens: the task's running pipeline. */
60
+ export const RUN_LINK_DEFAULT_VIEW = 'run'
61
+
62
+ /** What applying a run link can do, so which link opens what is testable without a board. */
63
+ export interface RunLinkSurfaces {
64
+ revealTask: (blockId: string) => void
65
+ /** The task's focus view, with the pipeline of its current run. */
66
+ openRun: (blockId: string) => void
67
+ openOutcome: (runId: string, blockId: string | null) => void
68
+ openObservability: (runId: string) => void
69
+ openTestEvidence: (runId: string) => void
70
+ }
71
+
72
+ /** Apply a captured link to a hydrated board: the task brought into view, then its view. */
73
+ export function applyRunDeepLink(link: PendingRunLink, surfaces: RunLinkSurfaces): void {
74
+ if (link.blockId) surfaces.revealTask(link.blockId)
75
+ switch (link.view ?? RUN_LINK_DEFAULT_VIEW) {
76
+ case 'run':
77
+ if (link.blockId) surfaces.openRun(link.blockId)
78
+ break
79
+ // `outcome` is passed the block as well as the run: it is the one view that stays readable on
80
+ // a task whose run the snapshot no longer carries, which a link followed weeks later is.
81
+ case 'outcome':
82
+ surfaces.openOutcome(link.runId, link.blockId)
83
+ break
84
+ case 'observability':
85
+ surfaces.openObservability(link.runId)
86
+ break
87
+ case 'test-evidence':
88
+ surfaces.openTestEvidence(link.runId)
89
+ break
90
+ default:
91
+ break
92
+ }
93
+ }
94
+
53
95
  /**
54
96
  * Capture a run deep link and, once the board has hydrated, apply it. Returns nothing; the
55
97
  * caller mounts it once, next to `workspace.init()`.
56
98
  *
57
99
  * Applying WAITS for `workspace.ready`: opening the panel against a half-loaded board would
58
- * read an execution the snapshot hasn't delivered yet. The watcher is one-shot — a later
59
- * refresh must not re-open a panel the user closed.
100
+ * read an execution the snapshot hasn't delivered yet, and the camera waits further, for the
101
+ * canvas to have drawn the frame it centres on. The watcher is one-shot: a later refresh must
102
+ * not re-open a panel the user closed.
60
103
  */
61
104
  export function useRunDeepLink(): void {
62
105
  const link = captureRunDeepLink()
@@ -64,16 +107,17 @@ export function useRunDeepLink(): void {
64
107
 
65
108
  const workspace = useWorkspaceStore()
66
109
  const ui = useUiStore()
110
+ const { revealTask } = useTaskReveal()
67
111
 
68
112
  // Pin the board the link names BEFORE init resolves the active one, so the run's own board
69
113
  // opens rather than whichever one was last used on this device.
70
114
  if (link.workspaceId) workspace.workspaceId = link.workspaceId
71
115
 
72
- // Applied at most once — a later refresh must not re-open a panel the user closed. The guard
73
- // is a flag rather than `{ once: true }` (which would burn the single run on the immediate
74
- // not-ready tick and never navigate) and `stop` is a `let` rather than a `const` (with
75
- // `immediate`, the callback runs synchronously BEFORE the binding initialises, so an
76
- // already-hydrated board would hit the temporal dead zone instead of navigating).
116
+ // Applied at most once. The guard is a flag rather than `{ once: true }` (which would burn the
117
+ // single run on the immediate not-ready tick and never navigate) and `stop` is a `let` rather
118
+ // than a `const` (with `immediate`, the callback runs synchronously BEFORE the binding
119
+ // initialises, so an already-hydrated board would hit the temporal dead zone instead of
120
+ // navigating).
77
121
  let applied = false
78
122
  let stop: (() => void) | undefined
79
123
  stop = watch(
@@ -81,20 +125,13 @@ export function useRunDeepLink(): void {
81
125
  (ready) => {
82
126
  if (!ready || applied) return
83
127
  applied = true
84
- if (link.blockId) ui.select(link.blockId)
85
- // Three views are served: the run's outcome summary (the non-code answer to "what did this
86
- // change"), the observability panel, and the Tester's result window (where the screenshots
87
- // the report's environment-lifecycle section lists are rendered). An unknown view still
88
- // lands the user on the right board and task rather than failing the navigation.
89
- //
90
- // `outcome` is passed the block as well as the run: it is the one of the three that stays
91
- // readable on a task whose run the snapshot no longer carries, and a link followed weeks
92
- // after the work merged is exactly that case. The engine emits no `view=outcome` link
93
- // today (its report links a REVIEWER to the two run-scoped panels); this is the entry
94
- // point for one, and for a URL a person shares.
95
- if (link.view === 'outcome') ui.openRunOutcome(link.runId, link.blockId)
96
- else if (link.view === 'observability') ui.openObservability(link.runId)
97
- else if (link.view === 'test-evidence') ui.openTestEvidence(link.runId)
128
+ applyRunDeepLink(link, {
129
+ revealTask,
130
+ openRun: (blockId) => ui.focus(blockId),
131
+ openOutcome: (runId, blockId) => ui.openRunOutcome(runId, blockId),
132
+ openObservability: (runId) => ui.openObservability(runId),
133
+ openTestEvidence: (runId) => ui.openTestEvidence(runId),
134
+ })
98
135
  stop?.()
99
136
  },
100
137
  { immediate: true },
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { LOD_MIN_ZOOM, zoomToLod } from './useSemanticZoom'
3
+
4
+ describe('zoomToLod', () => {
5
+ it.each([
6
+ [0.2, 'far'],
7
+ [0.59, 'far'],
8
+ [0.6, 'mid'],
9
+ [1.19, 'mid'],
10
+ [1.2, 'close'],
11
+ [1.8, 'steps'],
12
+ [2.39, 'steps'],
13
+ [2.4, 'subtasks'],
14
+ [3, 'subtasks'],
15
+ ] as const)('reads zoom %s as %s', (zoom, lod) => {
16
+ expect(zoomToLod(zoom)).toBe(lod)
17
+ })
18
+
19
+ it('starts each level at its LOD_MIN_ZOOM', () => {
20
+ for (const [lod, zoom] of Object.entries(LOD_MIN_ZOOM)) expect(zoomToLod(zoom)).toBe(lod)
21
+ })
22
+ })
@@ -3,6 +3,15 @@ import type { LodLevel } from '~/types/domain'
3
3
  /** The LOD scale, shallow → deep. Index order lets callers ask "is at least". */
4
4
  const LOD_ORDER: LodLevel[] = ['far', 'mid', 'close', 'steps', 'subtasks']
5
5
 
6
+ /** The zoom each level starts at; the level holds until the next one's. */
7
+ export const LOD_MIN_ZOOM: Record<LodLevel, number> = {
8
+ far: 0,
9
+ mid: 0.6,
10
+ close: 1.2,
11
+ steps: 1.8,
12
+ subtasks: 2.4,
13
+ }
14
+
6
15
  /** Map a raw zoom factor to a level-of-detail bucket. Shared by the main board
7
16
  * and the drill-down focus view so both honour the same thresholds.
8
17
  *
@@ -11,11 +20,7 @@ const LOD_ORDER: LodLevel[] = ['far', 'mid', 'close', 'steps', 'subtasks']
11
20
  * expands each step's live todo breakdown — the spatial analogue of opening a
12
21
  * task in the inspector. */
13
22
  export function zoomToLod(zoom: number): LodLevel {
14
- if (zoom < 0.6) return 'far'
15
- if (zoom < 1.2) return 'mid'
16
- if (zoom < 1.8) return 'close'
17
- if (zoom < 2.4) return 'steps'
18
- return 'subtasks'
23
+ return LOD_ORDER.findLast((level) => zoom >= LOD_MIN_ZOOM[level]) ?? 'far'
19
24
  }
20
25
 
21
26
  /** True when `lod` is at least as deep as `min` on the LOD scale. */
@@ -0,0 +1,26 @@
1
+ import { useFramePlacement } from '~/composables/useFramePlacement'
2
+ import { useBoardStore } from '~/stores/board'
3
+ import { useUiStore } from '~/stores/ui'
4
+ import { useWorkViewStore } from '~/stores/workView'
5
+
6
+ /**
7
+ * Bring a task in front of the person: the canvas rather than the queue, the task selected, and
8
+ * the camera centred on its card at the zoom where its pipeline steps render. What the queue's
9
+ * "show on board" does, and what a deep link naming a task does.
10
+ */
11
+ export function useTaskReveal() {
12
+ const board = useBoardStore()
13
+ const ui = useUiStore()
14
+ const views = useWorkViewStore()
15
+ const { focusTaskWhenRendered } = useFramePlacement()
16
+
17
+ function revealTask(taskId: string): void {
18
+ views.show('board')
19
+ ui.select(taskId)
20
+ const task = board.getBlock(taskId)
21
+ if (!task) return
22
+ void focusTaskWhenRendered(taskId, board.serviceOf(task)?.id ?? taskId)
23
+ }
24
+
25
+ return { revealTask }
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.319.1",
3
+ "version": "0.320.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",
@@ -20,7 +20,7 @@
20
20
  "access": "public"
21
21
  },
22
22
  "dependencies": {
23
- "@cat-factory/contracts": "0.373.0",
23
+ "@cat-factory/contracts": "0.373.1",
24
24
  "@modular-frontend/core": "0.6.0",
25
25
  "@modular-vue/core": "^1.5.0",
26
26
  "@modular-vue/journeys": "^1.4.0",
@@ -28,34 +28,34 @@
28
28
  "@modular-vue/runtime": "^1.4.1",
29
29
  "@modular-vue/vue": "^1.4.1",
30
30
  "@nuxt/fonts": "^0.14.0",
31
- "@nuxt/ui": "^4.11.3",
31
+ "@nuxt/ui": "^4.12.0",
32
32
  "@nuxtjs/i18n": "^10.6.0",
33
- "@pinia/nuxt": "^1.0.2",
33
+ "@pinia/nuxt": "^1.0.3",
34
34
  "@toad-contracts/core": "1.0.0",
35
- "@toad-contracts/frontend-http-client": "1.0.0",
35
+ "@toad-contracts/frontend-http-client": "1.0.1",
36
36
  "@toad-contracts/valibot": "1.0.0",
37
37
  "@vue-flow/background": "^1.3.2",
38
38
  "@vue-flow/core": "^1.48.2",
39
39
  "@vue-flow/node-resizer": "^1.5.1",
40
40
  "@vueuse/core": "^15.0.0",
41
- "markdown-it": "^15.0.2",
42
- "pinia": "^4.0.3",
41
+ "markdown-it": "^15.0.3",
42
+ "pinia": "^4.1.0",
43
43
  "pinia-plugin-persistedstate": "^4.7.1",
44
44
  "valibot": "^1.5.0",
45
- "vue": "3.5.43",
45
+ "vue": "3.5.44",
46
46
  "wretch": "^3.0.9"
47
47
  },
48
48
  "devDependencies": {
49
- "@storybook/vue3": "10.6.1",
49
+ "@storybook/vue3": "10.6.2",
50
50
  "@toad-contracts/testing": "1.0.0",
51
- "happy-dom": "^20.14.5",
52
- "msw": "^2.15.0",
51
+ "happy-dom": "^21.0.3",
52
+ "msw": "^3.0.3",
53
53
  "nuxt": "^4.6.1",
54
- "storybook": "10.6.1",
54
+ "storybook": "10.6.2",
55
55
  "typescript": "^6.0.3",
56
- "vitest": "^4.1.11",
56
+ "vitest": "^5.0.5",
57
57
  "vue-i18n-extract": "^2.0.7",
58
- "vue-tsc": "^3.3.12"
58
+ "vue-tsc": "^3.3.13"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "nuxt": "^4.5.2 <4.6.0 || ^4.6.1"