@cat-factory/app 0.275.0 → 0.277.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 +89 -2
- package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +13 -1
- package/app/components/binaryOutput/BinaryOutputReport.vue +15 -1
- package/app/components/binaryOutput/StoredAssetView.vue +134 -0
- package/app/components/board/AddTaskModal.vue +9 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +24 -10
- package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
- package/app/components/board/nodes/InitiativeCard.vue +9 -28
- package/app/components/board/nodes/LaneGroup.vue +93 -0
- package/app/components/board/nodes/LaneTask.vue +66 -0
- package/app/components/board/nodes/TaskCard.vue +16 -2
- package/app/components/board/nodes/TaskLane.vue +82 -0
- package/app/components/layout/BoardToolbar.vue +4 -0
- package/app/components/palettes/PipelinePurposeSelect.vue +1 -0
- package/app/components/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +61 -1
- package/app/composables/api/visualConfirm.ts +11 -2
- package/app/composables/useArtifactBlobs.spec.ts +76 -0
- package/app/composables/useArtifactBlobs.ts +25 -4
- package/app/composables/useBlockDrag.ts +47 -17
- package/app/composables/useBlockQueries.ts +27 -24
- package/app/composables/useFrameLanes.ts +177 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/stores/board/placement.ts +7 -0
- package/app/stores/board.spec.ts +119 -14
- package/app/stores/laneView.spec.ts +61 -0
- package/app/stores/laneView.ts +85 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/workspaceSettings.ts +4 -0
- package/app/utils/binaryCandidates.ts +19 -1
- package/app/utils/binaryOutput.ts +13 -0
- package/app/utils/catalog.ts +6 -0
- package/app/utils/framePlacement.ts +9 -4
- package/app/utils/laneGeometry.spec.ts +69 -0
- package/app/utils/laneGeometry.ts +104 -0
- package/app/utils/laneSort.spec.ts +236 -0
- package/app/utils/laneSort.ts +306 -0
- package/app/utils/swimlanes.spec.ts +259 -0
- package/app/utils/swimlanes.ts +355 -0
- package/i18n/locales/de.json +91 -5
- package/i18n/locales/en.json +91 -5
- package/i18n/locales/es.json +91 -5
- package/i18n/locales/fr.json +91 -5
- package/i18n/locales/he.json +91 -5
- package/i18n/locales/it.json +91 -5
- package/i18n/locales/ja.json +91 -5
- package/i18n/locales/pl.json +91 -5
- package/i18n/locales/tr.json +91 -5
- package/i18n/locales/uk.json +91 -5
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useArtifactBlobs } from '~/composables/useArtifactBlobs'
|
|
3
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
+
|
|
5
|
+
// What a resolved artifact reports about ITSELF, and why the answer is not the declaration that
|
|
6
|
+
// came with it.
|
|
7
|
+
//
|
|
8
|
+
// A stored asset carries two media types: the one the producing agent declared (optional,
|
|
9
|
+
// model-authored, a claim about a file) and the one the server SERVES the bytes as, after
|
|
10
|
+
// `blobResponseHeaders` has clamped anything outside the inline-image list down to
|
|
11
|
+
// `application/octet-stream`. Only the second is a fact about the response, so it is the one a
|
|
12
|
+
// surface may decide picture-versus-file from. Deciding from the declaration is wrong in both
|
|
13
|
+
// directions: an undeclared PNG renders as a generic file, and a mis-declared bundle renders as a
|
|
14
|
+
// broken `<img>` that reports itself as loaded, because the fetch genuinely succeeded.
|
|
15
|
+
|
|
16
|
+
function stubApi(contentType: string): { calls: number } {
|
|
17
|
+
const state = { calls: 0 }
|
|
18
|
+
vi.stubGlobal('useApi', () => ({
|
|
19
|
+
fetchArtifactBlob: async (_ws: string, id: string) => {
|
|
20
|
+
state.calls += 1
|
|
21
|
+
return { url: `blob:${id}`, contentType }
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
return state
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function blobs(contentType: string) {
|
|
28
|
+
const calls = stubApi(contentType)
|
|
29
|
+
useWorkspaceStore().workspaceId = 'ws_1'
|
|
30
|
+
return { blobs: useArtifactBlobs(), calls }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe('useArtifactBlobs', () => {
|
|
34
|
+
it('records the media type the server served, beside the URL', async () => {
|
|
35
|
+
const { blobs: cache } = blobs('image/png')
|
|
36
|
+
await cache.resolve('art_1')
|
|
37
|
+
expect(cache.urlFor('art_1')).toBe('blob:art_1')
|
|
38
|
+
expect(cache.typeFor('art_1')).toBe('image/png')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('reports the clamped type for bytes the server refused to serve inline', async () => {
|
|
42
|
+
// A GLB, a zip, a PDF, or an image type the allow-list does not carry: the response is an
|
|
43
|
+
// attachment, and a row that pointed an `<img>` at it would render a broken frame with the
|
|
44
|
+
// load reported as successful.
|
|
45
|
+
const { blobs: cache } = blobs('application/octet-stream')
|
|
46
|
+
await cache.resolve('art_1')
|
|
47
|
+
expect(cache.typeFor('art_1')).toBe('application/octet-stream')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('knows nothing about a type until the bytes are in hand', async () => {
|
|
51
|
+
// The loading state has no answer to give, which is what keeps a surface from committing to
|
|
52
|
+
// picture-or-file before the response says which.
|
|
53
|
+
const { blobs: cache } = blobs('image/png')
|
|
54
|
+
expect(cache.typeFor('art_1')).toBeUndefined()
|
|
55
|
+
await cache.resolve('art_1')
|
|
56
|
+
expect(cache.typeFor('art_1')).toBe('image/png')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('drops the recorded type on a retry, so a re-fetch cannot be read through the old one', async () => {
|
|
60
|
+
const { blobs: cache } = blobs('image/png')
|
|
61
|
+
await cache.resolve('art_1')
|
|
62
|
+
const pending = cache.retry('art_1')
|
|
63
|
+
expect(cache.typeFor('art_1')).toBeUndefined()
|
|
64
|
+
await pending
|
|
65
|
+
expect(cache.typeFor('art_1')).toBe('image/png')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('forgets every type when the owning component unmounts', async () => {
|
|
69
|
+
// The cache is per component and `revokeAll` is what releases the bytes; a type left behind
|
|
70
|
+
// would outlive the URL it describes.
|
|
71
|
+
const { blobs: cache } = blobs('image/png')
|
|
72
|
+
await cache.resolve('art_1')
|
|
73
|
+
cache.revokeAll()
|
|
74
|
+
expect(cache.typeFor('art_1')).toBeUndefined()
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -23,6 +23,17 @@ export function useArtifactBlobs() {
|
|
|
23
23
|
|
|
24
24
|
/** artifactId → object URL (reactive so templates re-render when a blob resolves). */
|
|
25
25
|
const urls = reactive<Record<string, string>>({})
|
|
26
|
+
/**
|
|
27
|
+
* artifactId → the media type the SERVER served these bytes as.
|
|
28
|
+
*
|
|
29
|
+
* The one a surface may decide from. A stored artifact also carries a DECLARED content type,
|
|
30
|
+
* which for an asset is the agent's own claim about the file it uploaded and is optional; the
|
|
31
|
+
* serve path meanwhile clamps anything outside the inline-image list to
|
|
32
|
+
* `application/octet-stream`. So a row deciding picture-versus-file from the declaration gets it
|
|
33
|
+
* wrong in both directions: an undeclared PNG renders as a generic file, and a mis-declared
|
|
34
|
+
* bundle renders as a broken `<img>` with the load reported as successful.
|
|
35
|
+
*/
|
|
36
|
+
const types = reactive<Record<string, string>>({})
|
|
26
37
|
/** artifactId → fetch status, drives loading / error / retry affordances. */
|
|
27
38
|
const status = reactive<Record<string, ArtifactBlobStatus>>({})
|
|
28
39
|
/** In-flight promises, so concurrent `resolve(id)` calls share one fetch + one blob. */
|
|
@@ -43,6 +54,11 @@ export function useArtifactBlobs() {
|
|
|
43
54
|
return id ? (status[id] ?? 'idle') : 'idle'
|
|
44
55
|
}
|
|
45
56
|
|
|
57
|
+
/** The media type the server served this artifact as, once its bytes are in hand. */
|
|
58
|
+
function typeFor(id: string | null | undefined): string | undefined {
|
|
59
|
+
return id ? types[id] : undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
46
62
|
/** Resolve an artifact to an object URL (cached + deduped). Returns null on failure. */
|
|
47
63
|
function resolve(id: string | null | undefined): Promise<string | null> {
|
|
48
64
|
if (!id || disposed) return Promise.resolve(null)
|
|
@@ -53,19 +69,22 @@ export function useArtifactBlobs() {
|
|
|
53
69
|
|
|
54
70
|
status[id] = 'loading'
|
|
55
71
|
const p = api
|
|
56
|
-
.
|
|
57
|
-
.then((url) => {
|
|
72
|
+
.fetchArtifactBlob(ws.requireId(), id)
|
|
73
|
+
.then(({ url, contentType }) => {
|
|
58
74
|
// The owner unmounted while this was in flight: revoke the freshly-minted URL
|
|
59
75
|
// instead of stranding it in the cleared cache.
|
|
60
76
|
if (disposed) {
|
|
61
77
|
try {
|
|
62
78
|
URL.revokeObjectURL(url)
|
|
63
79
|
} catch {
|
|
64
|
-
// Already revoked / unsupported environment
|
|
80
|
+
// Already revoked / unsupported environment, nothing to do.
|
|
65
81
|
}
|
|
66
82
|
return null
|
|
67
83
|
}
|
|
68
84
|
urls[id] = url
|
|
85
|
+
// Written BEFORE the status flips to `ready`, so a watcher reacting to the status never
|
|
86
|
+
// reads a resolved artifact whose served type has not landed yet.
|
|
87
|
+
if (contentType) types[id] = contentType
|
|
69
88
|
status[id] = 'ready'
|
|
70
89
|
return url
|
|
71
90
|
})
|
|
@@ -91,6 +110,7 @@ export function useArtifactBlobs() {
|
|
|
91
110
|
}
|
|
92
111
|
}
|
|
93
112
|
delete urls[id]
|
|
113
|
+
delete types[id]
|
|
94
114
|
status[id] = 'idle'
|
|
95
115
|
inFlight.delete(id)
|
|
96
116
|
return resolve(id)
|
|
@@ -110,11 +130,12 @@ export function useArtifactBlobs() {
|
|
|
110
130
|
}
|
|
111
131
|
}
|
|
112
132
|
for (const k of Object.keys(urls)) delete urls[k]
|
|
133
|
+
for (const k of Object.keys(types)) delete types[k]
|
|
113
134
|
for (const k of Object.keys(status)) delete status[k]
|
|
114
135
|
inFlight.clear()
|
|
115
136
|
}
|
|
116
137
|
|
|
117
|
-
return { urls, status, urlFor, statusFor, resolve, retry, revokeAll }
|
|
138
|
+
return { urls, types, status, urlFor, statusFor, typeFor, resolve, retry, revokeAll }
|
|
118
139
|
}
|
|
119
140
|
|
|
120
141
|
export type ArtifactBlobs = ReturnType<typeof useArtifactBlobs>
|
|
@@ -9,11 +9,18 @@ const draggingId = ref<string | null>(null)
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Pointer-driven dragging for blocks positioned inside a container's 2D canvas
|
|
12
|
-
* (
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* (initiative cards inside services) and for free-floating service frames (via
|
|
13
|
+
* their header handle). Movement is divided by the board zoom so the block tracks
|
|
14
|
+
* the cursor. When `reparent` is set, the drop point is hit-tested against
|
|
15
|
+
* `[data-drop-zone]` ancestors so a block can be dragged from a service into a
|
|
16
|
+
* module (or back out).
|
|
17
|
+
*
|
|
18
|
+
* A TASK is a `positioned: false` drag, because tasks are laid out in swimlanes and
|
|
19
|
+
* carry no coordinates a reader can see. Such a drag previews nothing and commits
|
|
20
|
+
* nothing on a same-container drop: its ONLY effect is a reparent, which is what a
|
|
21
|
+
* task drag is still for (moving work between services, and into or out of a module).
|
|
22
|
+
* A position write there would persist coordinates nothing renders and emit a board
|
|
23
|
+
* event for a change with no visible result.
|
|
17
24
|
*/
|
|
18
25
|
export function useBlockDrag() {
|
|
19
26
|
const board = useBoardStore()
|
|
@@ -23,7 +30,7 @@ export function useBlockDrag() {
|
|
|
23
30
|
function startDrag(
|
|
24
31
|
block: Block,
|
|
25
32
|
e: PointerEvent,
|
|
26
|
-
opts: { reparent?: boolean; clamp?: boolean } = {},
|
|
33
|
+
opts: { reparent?: boolean; clamp?: boolean; positioned?: boolean } = {},
|
|
27
34
|
) {
|
|
28
35
|
if (e.button !== 0) return
|
|
29
36
|
// Read-only viewers can pan/inspect but never move or reparent a block — the drag
|
|
@@ -35,9 +42,11 @@ export function useBlockDrag() {
|
|
|
35
42
|
const startX = e.clientX
|
|
36
43
|
const startY = e.clientY
|
|
37
44
|
const orig = { ...block.position }
|
|
38
|
-
// Container-local blocks (
|
|
39
|
-
// frames live in free-floating flow space, so they opt out via `clamp: false`.
|
|
45
|
+
// Container-local blocks (initiative cards) are clamped to their parent's origin;
|
|
46
|
+
// frames live in free-floating flow space, so they opt out via `clamp: false`. Inert for
|
|
47
|
+
// a `positioned: false` drag, which never writes a position at all.
|
|
40
48
|
const clamp = opts.clamp ?? true
|
|
49
|
+
const positioned = opts.positioned ?? true
|
|
41
50
|
draggingId.value = block.id
|
|
42
51
|
// Position is only previewed locally while dragging and persisted once on
|
|
43
52
|
// release. Writing every move raced — a late, out-of-order response could land
|
|
@@ -51,7 +60,10 @@ export function useBlockDrag() {
|
|
|
51
60
|
const ny = orig.y + (ev.clientY - startY) / z
|
|
52
61
|
moved = true
|
|
53
62
|
last = { x: clamp ? Math.max(0, nx) : nx, y: clamp ? Math.max(0, ny) : ny }
|
|
54
|
-
|
|
63
|
+
// A lane task has nowhere to preview TO: its place in the column is derived from its
|
|
64
|
+
// status and the reader's sort, so following the cursor would be a lie the drop then
|
|
65
|
+
// undoes. The `draggingId` state the card dims itself with is the whole feedback.
|
|
66
|
+
if (positioned) board.previewMove(block.id, last)
|
|
55
67
|
}
|
|
56
68
|
const onUp = (ev: PointerEvent) => {
|
|
57
69
|
window.removeEventListener('pointermove', onMove)
|
|
@@ -60,9 +72,9 @@ export function useBlockDrag() {
|
|
|
60
72
|
// A successful reparent persists the move itself; otherwise commit the final
|
|
61
73
|
// position in place. Either way it's a single write, not one per frame. Run
|
|
62
74
|
// the hit-test BEFORE clearing draggingId so the dragged element is still
|
|
63
|
-
// marked non-interactive (see
|
|
64
|
-
const reparented = opts.reparent && reparentAt(block, ev.clientX, ev.clientY)
|
|
65
|
-
if (!reparented) void board.moveBlock(block.id, last)
|
|
75
|
+
// marked non-interactive (see LaneTask) and the zone beneath resolves.
|
|
76
|
+
const reparented = opts.reparent && reparentAt(block, ev.clientX, ev.clientY, positioned)
|
|
77
|
+
if (!reparented && positioned) void board.moveBlock(block.id, last)
|
|
66
78
|
}
|
|
67
79
|
draggingId.value = null
|
|
68
80
|
}
|
|
@@ -71,10 +83,15 @@ export function useBlockDrag() {
|
|
|
71
83
|
}
|
|
72
84
|
|
|
73
85
|
/** Returns true when the block was dropped into a *different* container. */
|
|
74
|
-
function reparentAt(
|
|
86
|
+
function reparentAt(
|
|
87
|
+
block: Block,
|
|
88
|
+
clientX: number,
|
|
89
|
+
clientY: number,
|
|
90
|
+
positioned: boolean,
|
|
91
|
+
): boolean {
|
|
75
92
|
const el = document.querySelector(`[data-block-id="${block.id}"]`) as HTMLElement | null
|
|
76
93
|
if (!el) return false
|
|
77
|
-
// The dragged block is already non-interactive while dragging (
|
|
94
|
+
// The dragged block is already non-interactive while dragging (LaneTask
|
|
78
95
|
// drops pointer-events on the whole wrapper, handle included); belt-and-braces,
|
|
79
96
|
// also neutralise this node so elementFromPoint resolves the zone beneath it.
|
|
80
97
|
const prev = el.style.pointerEvents
|
|
@@ -87,14 +104,27 @@ export function useBlockDrag() {
|
|
|
87
104
|
const newParent = zoneEl.getAttribute('data-drop-zone')!
|
|
88
105
|
if (newParent === block.parentId) return false // same container — caller commits position
|
|
89
106
|
|
|
107
|
+
void board.reparentBlock(block.id, newParent, positionIn(zoneEl, el, positioned))
|
|
108
|
+
return true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where the dropped block lands in its new container.
|
|
113
|
+
*
|
|
114
|
+
* A lane task gets the origin, not the coordinates it happened to be released over.
|
|
115
|
+
* Its place in the new container is derived from its status and the reader's sort, so a
|
|
116
|
+
* captured offset would be a coordinate nothing reads and every later reader would have
|
|
117
|
+
* to wonder whether it meant something.
|
|
118
|
+
*/
|
|
119
|
+
function positionIn(zoneEl: HTMLElement, el: HTMLElement, positioned: boolean) {
|
|
120
|
+
if (!positioned) return { x: 0, y: 0 }
|
|
90
121
|
const z = ui.zoom || 1
|
|
91
122
|
const zr = zoneEl.getBoundingClientRect()
|
|
92
123
|
const er = el.getBoundingClientRect()
|
|
93
|
-
|
|
124
|
+
return {
|
|
94
125
|
x: Math.max(0, (er.left - zr.left) / z),
|
|
95
126
|
y: Math.max(0, (er.top - zr.top) / z),
|
|
96
|
-
}
|
|
97
|
-
return true
|
|
127
|
+
}
|
|
98
128
|
}
|
|
99
129
|
|
|
100
130
|
return { draggingId, startDrag }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, type Ref } from 'vue'
|
|
2
2
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
3
|
+
import { frameContentSize, LANE_GEOMETRY } from '~/utils/laneGeometry'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Pure, read-only queries over a board's blocks. Extracted from the board store
|
|
@@ -162,33 +163,35 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
/**
|
|
165
|
-
* The natural extent of a
|
|
166
|
-
*
|
|
167
|
-
*
|
|
166
|
+
* The natural extent of a frame's inner canvas — the smallest size that fits its swimlanes
|
|
167
|
+
* and its initiative band. This is the floor a resizable frame can never be dragged below.
|
|
168
|
+
*
|
|
169
|
+
* Task positions are deliberately NOT consulted any more. Tasks are laid out in status
|
|
170
|
+
* lanes, so the frame's size is a function of the LANE GEOMETRY, not of where cards happen
|
|
171
|
+
* to sit, and each lane SCROLLS rather than growing without bound. That decoupling is what
|
|
172
|
+
* fixes the old behaviour where a service accumulating work grew a taller and taller frame
|
|
173
|
+
* until it dwarfed its neighbours, and it is also what keeps this function pure over blocks:
|
|
174
|
+
* a lane's population depends on run state, which this layer cannot see and must not need to.
|
|
175
|
+
*
|
|
176
|
+
* The arithmetic itself lives in `frameContentSize` so the placement helper, which sizes a
|
|
177
|
+
* frame that does not exist yet, reserves the same footprint this one will render at.
|
|
168
178
|
*/
|
|
169
179
|
function contentSize(id: string): { w: number; h: number } {
|
|
170
180
|
const b = getBlock(id)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
// Initiative cards render inside the frame's drop zone like tasks (230×~170).
|
|
187
|
-
for (const i of initiativesOf(id)) {
|
|
188
|
-
w = Math.max(w, i.position.x + 230 + 12)
|
|
189
|
-
inner = Math.max(inner, i.position.y + 170 + 12)
|
|
190
|
-
}
|
|
191
|
-
return { w, h: inner + headerH }
|
|
181
|
+
// A module is no longer drawn as a box (its tasks appear in the frame's lanes, grouped by
|
|
182
|
+
// module name), so it has no canvas of its own. A minimal size keeps any incidental caller
|
|
183
|
+
// honest rather than returning zero, which would read as "measured, and empty".
|
|
184
|
+
if (b?.level === 'module') return { w: LANE_GEOMETRY.laneWidth, h: 0 }
|
|
185
|
+
|
|
186
|
+
const initiatives = initiativesOf(id)
|
|
187
|
+
return frameContentSize({
|
|
188
|
+
// The predicate `BlockNode` renders the lanes on: an empty service shows one "add the first
|
|
189
|
+
// task" panel instead, and reserving lane-sized space for it would leave the frame two and
|
|
190
|
+
// a half times taller than its own contents.
|
|
191
|
+
hasChildren:
|
|
192
|
+
allTasksUnder(id).length > 0 || modulesOf(id).length > 0 || initiatives.length > 0,
|
|
193
|
+
initiatives: initiatives.length,
|
|
194
|
+
})
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
/**
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { computed, type Ref } from 'vue'
|
|
2
|
+
import { collectReviewDebt } from '@cat-factory/contracts'
|
|
3
|
+
import type { Block } from '~/types/domain'
|
|
4
|
+
import {
|
|
5
|
+
groupLaneTasks,
|
|
6
|
+
runActivityAt,
|
|
7
|
+
runWaitingSince,
|
|
8
|
+
sortLaneTasks,
|
|
9
|
+
type LaneGroup,
|
|
10
|
+
type LaneTaskEntry,
|
|
11
|
+
} from '~/utils/laneSort'
|
|
12
|
+
import {
|
|
13
|
+
classifyTask,
|
|
14
|
+
selectDoneLaneTasks,
|
|
15
|
+
TASK_LANES,
|
|
16
|
+
type DoneLaneSelection,
|
|
17
|
+
type TaskLane,
|
|
18
|
+
} from '~/utils/swimlanes'
|
|
19
|
+
|
|
20
|
+
/** One rendered lane: its identity, its groups, and the count its header states. */
|
|
21
|
+
export interface RenderedLane {
|
|
22
|
+
readonly lane: TaskLane
|
|
23
|
+
readonly groups: LaneGroup[]
|
|
24
|
+
/** Every task classified into this lane, BEFORE the Done lane's caps. */
|
|
25
|
+
readonly total: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A lane entry plus the lane it was classified into. */
|
|
29
|
+
interface ClassifiedEntry {
|
|
30
|
+
readonly entry: LaneTaskEntry
|
|
31
|
+
readonly lane: TaskLane
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Assemble a service frame's tasks into swimlanes.
|
|
36
|
+
*
|
|
37
|
+
* This is the store-facing half of the lane model: it resolves every input the pure
|
|
38
|
+
* `classifyTask` / `sortLaneTasks` / `groupLaneTasks` functions need and nothing more, so the
|
|
39
|
+
* classification and ordering rules stay testable without a Pinia instance. It also keeps the
|
|
40
|
+
* per-frame cost linear in the frame's tasks: every cross-block lookup below is a Map read off
|
|
41
|
+
* an index the stores already maintain, never a scan per task.
|
|
42
|
+
*/
|
|
43
|
+
export function useFrameLanes(frameId: Ref<string>) {
|
|
44
|
+
const board = useBoardStore()
|
|
45
|
+
const execution = useExecutionStore()
|
|
46
|
+
const agentRuns = useAgentRunsStore()
|
|
47
|
+
const notifications = useNotificationsStore()
|
|
48
|
+
const settings = useWorkspaceSettingsStore()
|
|
49
|
+
const laneView = useLaneViewStore()
|
|
50
|
+
const reviews = useReviewStage()
|
|
51
|
+
|
|
52
|
+
/** Tasks directly in the frame plus those inside its modules: a module renders no box now. */
|
|
53
|
+
const tasks = computed(() => board.allTasksUnder(frameId.value))
|
|
54
|
+
|
|
55
|
+
/** Module name → the module BLOCK that materialises it, so a group header can be a drop zone. */
|
|
56
|
+
const moduleBlockIdByName = computed(
|
|
57
|
+
() => new Map(board.modulesOf(frameId.value).map((m) => [m.title, m.id])),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Per-block "waiting since", derived once from the workspace's open review-wait cards by the
|
|
62
|
+
* same `collectReviewDebt` the backend's friction check uses. It is the fallback source for
|
|
63
|
+
* the park surfaces that stamp no `step.pausedAt`; deriving it once rather than per task is
|
|
64
|
+
* what keeps this assembly linear.
|
|
65
|
+
*/
|
|
66
|
+
const waitingSinceByBlock = computed(
|
|
67
|
+
() => new Map(collectReviewDebt(notifications.open).map((d) => [d.blockId, d.waitingSince])),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The module a task belongs to: the module BLOCK's title when it already lives in one, else
|
|
72
|
+
* the module it DECLARES. The engine only materialises the block on merge
|
|
73
|
+
* (`applyModuleAssignment`), so keying on the parent alone would leave every unmerged task in
|
|
74
|
+
* "no module" while its own card names one.
|
|
75
|
+
*/
|
|
76
|
+
function moduleNameOf(task: Block): string | null {
|
|
77
|
+
const parent = task.parentId ? board.getBlock(task.parentId) : undefined
|
|
78
|
+
if (parent?.level === 'module') return parent.title
|
|
79
|
+
return task.moduleName?.trim() || null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function classify(task: Block, order: number): ClassifiedEntry {
|
|
83
|
+
const run = execution.getByBlock(task.id) ?? null
|
|
84
|
+
const decisions = execution.decisionsByBlock.get(task.id) ?? []
|
|
85
|
+
const allApprovals = execution.approvalsByBlock.get(task.id) ?? []
|
|
86
|
+
// The same suppression the card and the frame badge apply: an iterative reviewer mid-cycle
|
|
87
|
+
// holds a pending approval while the driver folds answers in, and nobody is waiting on it.
|
|
88
|
+
const humanApprovals = allApprovals.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
|
|
89
|
+
|
|
90
|
+
const { lane, reason } = classifyTask({
|
|
91
|
+
status: task.status,
|
|
92
|
+
// Read from the coarse per-block summary, which also covers a bootstrap run.
|
|
93
|
+
runFailed: agentRuns.byBlock[task.id]?.status === 'failed',
|
|
94
|
+
run,
|
|
95
|
+
// A park is background exactly when everything asking was suppressed AND nothing else
|
|
96
|
+
// asks. With no approvals at all it is NOT background: it is a park on a surface this
|
|
97
|
+
// layer cannot name, which `classifyTask` reports as `parked` rather than as work.
|
|
98
|
+
parkIsBackground:
|
|
99
|
+
decisions.length === 0 &&
|
|
100
|
+
humanApprovals.length === 0 &&
|
|
101
|
+
allApprovals.length > humanApprovals.length,
|
|
102
|
+
pendingDecision: decisions.length > 0,
|
|
103
|
+
pendingApproval: humanApprovals.length > 0,
|
|
104
|
+
hasUnmetDeps: board.unmetDeps(task.id).length > 0,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
lane,
|
|
109
|
+
entry: {
|
|
110
|
+
task,
|
|
111
|
+
reason,
|
|
112
|
+
order,
|
|
113
|
+
activityAt: runActivityAt(run),
|
|
114
|
+
waitingSince: runWaitingSince(run, waitingSinceByBlock.value.get(task.id) ?? null),
|
|
115
|
+
moduleName: moduleNameOf(task),
|
|
116
|
+
initiativeName: task.initiativeId
|
|
117
|
+
? (board.getBlock(task.initiativeId)?.title ?? null)
|
|
118
|
+
: null,
|
|
119
|
+
epicName: board.epicOf(task)?.title ?? null,
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Every task bucketed by lane, in board order, before sorting. */
|
|
125
|
+
const byLane = computed(() => {
|
|
126
|
+
const buckets = new Map<TaskLane, LaneTaskEntry[]>(TASK_LANES.map((lane) => [lane, []]))
|
|
127
|
+
tasks.value.forEach((task, order) => {
|
|
128
|
+
const { lane, entry } = classify(task, order)
|
|
129
|
+
buckets.get(lane)!.push(entry)
|
|
130
|
+
})
|
|
131
|
+
return buckets
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What the Done lane renders, and a full account of what it withheld.
|
|
136
|
+
*
|
|
137
|
+
* Computed even while the lane is collapsed, because the collapsed header states the TOTAL:
|
|
138
|
+
* "this service has finished 312 tasks" is the fact the lane exists to carry, and a header
|
|
139
|
+
* counting only what it happens to render would understate it by two orders of magnitude.
|
|
140
|
+
*
|
|
141
|
+
* `Date.now()` is read non-reactively, as `useReviewDebt` does. The cutoff is re-evaluated
|
|
142
|
+
* whenever the board, the runs or the settings change, which on a live board is constantly; a
|
|
143
|
+
* ticking clock purely so a card could vanish mid-session would be motion nobody asked for.
|
|
144
|
+
*/
|
|
145
|
+
const doneSelection = computed<DoneLaneSelection>(() =>
|
|
146
|
+
selectDoneLaneTasks(
|
|
147
|
+
(byLane.value.get('done') ?? []).map((e) => e.task),
|
|
148
|
+
{
|
|
149
|
+
maxItems: settings.settings.doneLaneMaxItems,
|
|
150
|
+
retentionDays: settings.settings.doneLaneRetentionDays,
|
|
151
|
+
},
|
|
152
|
+
Date.now(),
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
const lanes = computed<RenderedLane[]>(() =>
|
|
157
|
+
TASK_LANES.map((lane) => {
|
|
158
|
+
const bucket = byLane.value.get(lane) ?? []
|
|
159
|
+
// Only the Done lane is capped; every other lane renders everything in it.
|
|
160
|
+
const visible = lane === 'done' ? admittedByCaps(bucket, doneSelection.value) : bucket
|
|
161
|
+
const ordered = sortLaneTasks(visible, laneView.sortKey, lane)
|
|
162
|
+
return {
|
|
163
|
+
lane,
|
|
164
|
+
groups: groupLaneTasks(ordered, laneView.groupKey, moduleBlockIdByName.value),
|
|
165
|
+
total: bucket.length,
|
|
166
|
+
}
|
|
167
|
+
}),
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return { lanes, doneSelection }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The entries whose task survived the Done lane's caps. */
|
|
174
|
+
function admittedByCaps(entries: LaneTaskEntry[], selection: DoneLaneSelection): LaneTaskEntry[] {
|
|
175
|
+
const admitted = new Set(selection.shown.map((task) => task.id))
|
|
176
|
+
return entries.filter((e) => admitted.has(e.task.id))
|
|
177
|
+
}
|
|
@@ -75,7 +75,7 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: B
|
|
|
75
75
|
//
|
|
76
76
|
// Blocks with no pipeline to show are filtered out here rather than left to the card:
|
|
77
77
|
// a frame, a module, or a task with no run expands to nothing, and granting it would
|
|
78
|
-
// still lift an empty card over its neighbours (see
|
|
78
|
+
// still lift an empty card over its neighbours (see LaneTask's z-index).
|
|
79
79
|
function hoveredTaskId(): string | null {
|
|
80
80
|
if (!pointer) return null
|
|
81
81
|
const hit = document.elementFromPoint(pointer.x, pointer.y)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { UpdateBlockInput } from '@cat-factory/contracts'
|
|
2
|
+
import { moduleNameInContainer } from '@cat-factory/contracts'
|
|
2
3
|
import { useServicesStore } from '~/stores/services'
|
|
3
4
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
5
|
import { createBoardDependencies } from './dependencies'
|
|
@@ -59,9 +60,14 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
59
60
|
// block in the wrong container (a structural lie that survives until re-hydrate).
|
|
60
61
|
const prevParentId = b.parentId
|
|
61
62
|
const prevPosition = b.position
|
|
63
|
+
const prevModuleName = b.moduleName
|
|
62
64
|
const name = b.title
|
|
63
65
|
b.parentId = newParentId
|
|
64
66
|
b.position = position
|
|
67
|
+
// The server re-stamps a moved task's declared module from its new container, so predict the
|
|
68
|
+
// same answer here: without it the card lands in the lane group it was dragged OUT of and
|
|
69
|
+
// jumps to the right one when the response arrives.
|
|
70
|
+
if (b.level === 'task') b.moduleName = moduleNameInContainer(parent)
|
|
65
71
|
try {
|
|
66
72
|
upsert(
|
|
67
73
|
await api.reparentBlock(useWorkspaceStore().requireId(), id, {
|
|
@@ -90,6 +96,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
90
96
|
} catch (e) {
|
|
91
97
|
b.parentId = prevParentId
|
|
92
98
|
b.position = prevPosition
|
|
99
|
+
b.moduleName = prevModuleName
|
|
93
100
|
// A cross-home drag can be refused on merge-preset grounds, which is a condition the mover
|
|
94
101
|
// can act on rather than a fault. The backend sends the machine-readable reason and no
|
|
95
102
|
// translated prose, so map it here; anything else keeps the raw message as the last resort.
|