@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.
Files changed (55) hide show
  1. package/README.md +89 -2
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +13 -1
  3. package/app/components/binaryOutput/BinaryOutputReport.vue +15 -1
  4. package/app/components/binaryOutput/StoredAssetView.vue +134 -0
  5. package/app/components/board/AddTaskModal.vue +9 -4
  6. package/app/components/board/LaneViewControl.vue +87 -0
  7. package/app/components/board/nodes/BlockNode.vue +24 -10
  8. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  9. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  10. package/app/components/board/nodes/LaneGroup.vue +93 -0
  11. package/app/components/board/nodes/LaneTask.vue +66 -0
  12. package/app/components/board/nodes/TaskCard.vue +16 -2
  13. package/app/components/board/nodes/TaskLane.vue +82 -0
  14. package/app/components/layout/BoardToolbar.vue +4 -0
  15. package/app/components/palettes/PipelinePurposeSelect.vue +1 -0
  16. package/app/components/panels/InspectorPanel.vue +15 -2
  17. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  18. package/app/components/settings/WorkspaceSettingsPanel.vue +61 -1
  19. package/app/composables/api/visualConfirm.ts +11 -2
  20. package/app/composables/useArtifactBlobs.spec.ts +76 -0
  21. package/app/composables/useArtifactBlobs.ts +25 -4
  22. package/app/composables/useBlockDrag.ts +47 -17
  23. package/app/composables/useBlockQueries.ts +27 -24
  24. package/app/composables/useFrameLanes.ts +177 -0
  25. package/app/composables/useTaskExpansion.ts +1 -1
  26. package/app/stores/board/placement.ts +7 -0
  27. package/app/stores/board.spec.ts +119 -14
  28. package/app/stores/laneView.spec.ts +61 -0
  29. package/app/stores/laneView.ts +85 -0
  30. package/app/stores/taskExpansion.spec.ts +1 -1
  31. package/app/stores/taskExpansion.ts +1 -1
  32. package/app/stores/workspaceSettings.ts +4 -0
  33. package/app/utils/binaryCandidates.ts +19 -1
  34. package/app/utils/binaryOutput.ts +13 -0
  35. package/app/utils/catalog.ts +6 -0
  36. package/app/utils/framePlacement.ts +9 -4
  37. package/app/utils/laneGeometry.spec.ts +69 -0
  38. package/app/utils/laneGeometry.ts +104 -0
  39. package/app/utils/laneSort.spec.ts +236 -0
  40. package/app/utils/laneSort.ts +306 -0
  41. package/app/utils/swimlanes.spec.ts +259 -0
  42. package/app/utils/swimlanes.ts +355 -0
  43. package/i18n/locales/de.json +91 -5
  44. package/i18n/locales/en.json +91 -5
  45. package/i18n/locales/es.json +91 -5
  46. package/i18n/locales/fr.json +91 -5
  47. package/i18n/locales/he.json +91 -5
  48. package/i18n/locales/it.json +91 -5
  49. package/i18n/locales/ja.json +91 -5
  50. package/i18n/locales/pl.json +91 -5
  51. package/i18n/locales/tr.json +91 -5
  52. package/i18n/locales/uk.json +91 -5
  53. package/package.json +2 -2
  54. package/app/components/board/nodes/DraggableTask.vue +0 -58
  55. 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
- .fetchArtifactBlobUrl(ws.requireId(), id)
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 nothing to do.
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
- * (tasks inside services/modules, modules inside services) and for free-floating
13
- * service frames (via their header handle). Movement is divided by the board zoom
14
- * so the block tracks the cursor. When `reparent` is set, the drop point is
15
- * hit-tested against `[data-drop-zone]` ancestors so a task can be dragged from a
16
- * service into a module (or back out).
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 (tasks/modules) are clamped to their parent's origin;
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
- board.previewMove(block.id, last)
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 DraggableTask) and the zone beneath resolves.
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(block: Block, clientX: number, clientY: number): boolean {
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 (DraggableTask
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
- void board.reparentBlock(block.id, newParent, {
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 container's inner 2D canvas — the smallest size that
166
- * still fits all its children. This is the floor a resizable frame can never be
167
- * dragged below (so tasks/modules are never clipped).
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
- const isModule = b?.level === 'module'
172
- const TASK_W = 210
173
- const TASK_H = 160
174
- const headerH = isModule ? 30 : 0
175
- let w = isModule ? 200 : 360
176
- let inner = isModule ? 60 : 220
177
- for (const t of tasksOf(id)) {
178
- w = Math.max(w, t.position.x + TASK_W + 12)
179
- inner = Math.max(inner, t.position.y + TASK_H + 12)
180
- }
181
- for (const m of modulesOf(id)) {
182
- const s = containerSize(m.id)
183
- w = Math.max(w, m.position.x + s.w + 12)
184
- inner = Math.max(inner, m.position.y + s.h + 12)
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 DraggableTask's z-index).
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.