@cat-factory/app 0.275.0 → 0.276.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/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/panels/InspectorPanel.vue +15 -2
- package/app/components/panels/inspector/TaskStructure.vue +70 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
- 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/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 +78 -3
- package/i18n/locales/en.json +78 -3
- package/i18n/locales/es.json +78 -3
- package/i18n/locales/fr.json +78 -3
- package/i18n/locales/he.json +78 -3
- package/i18n/locales/it.json +78 -3
- package/i18n/locales/ja.json +78 -3
- package/i18n/locales/pl.json +78 -3
- package/i18n/locales/tr.json +78 -3
- package/i18n/locales/uk.json +78 -3
- 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,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.
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { setActivePinia, createPinia } from 'pinia'
|
|
|
3
3
|
import type { Block, BlockStatus } from '~/types/domain'
|
|
4
4
|
import { useBoardStore } from '~/stores/board'
|
|
5
5
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
6
|
+
import { EMPTY_FRAME_SIZE } from '~/utils/framePlacement'
|
|
7
|
+
import { frameContentSize, laneBodyHeightIn, LANE_GEOMETRY } from '~/utils/laneGeometry'
|
|
6
8
|
|
|
7
9
|
/** Minimal Block factory — only the fields the read getters care about. */
|
|
8
10
|
function block(id: string, over: Partial<Block> = {}): Block {
|
|
@@ -27,6 +29,8 @@ const moduleBlock = (id: string, parentId: string, over: Partial<Block> = {}) =>
|
|
|
27
29
|
block(id, { level: 'module', parentId, ...over })
|
|
28
30
|
const task = (id: string, parentId: string, over: Partial<Block> = {}) =>
|
|
29
31
|
block(id, { level: 'task', parentId, ...over })
|
|
32
|
+
const initiativeBlock = (id: string, parentId: string, over: Partial<Block> = {}) =>
|
|
33
|
+
block(id, { level: 'initiative', parentId, ...over })
|
|
30
34
|
|
|
31
35
|
describe('board store read getters', () => {
|
|
32
36
|
let store: ReturnType<typeof useBoardStore>
|
|
@@ -212,29 +216,77 @@ describe('board store read getters', () => {
|
|
|
212
216
|
})
|
|
213
217
|
|
|
214
218
|
describe('containerSize', () => {
|
|
215
|
-
|
|
219
|
+
// A frame's size is now a function of the LANE GEOMETRY, not of its contents. That is the
|
|
220
|
+
// point of the swimlanes: each lane scrolls, so a service accumulating work no longer grows
|
|
221
|
+
// a taller and taller frame until it dwarfs its neighbours. These tests pin the INVARIANT
|
|
222
|
+
// (size independent of task count and task position) rather than the pixel arithmetic, which
|
|
223
|
+
// belongs to `LANE_GEOMETRY` and would otherwise be restated here to no purpose.
|
|
224
|
+
it('sizes a service with nothing in it to the panel it actually renders', () => {
|
|
225
|
+
// An empty service shows one "add the first task" panel, not lanes, so it reserves the
|
|
226
|
+
// panel's footprint. Reserving the lanes' would leave the frame more than twice as tall as
|
|
227
|
+
// its own contents — and, since a placement clears frames by their reserved size, would
|
|
228
|
+
// push its neighbours that much further away for a frame holding nothing.
|
|
216
229
|
store.hydrate([frame('f1')])
|
|
217
|
-
expect(store.containerSize('f1')).toEqual(
|
|
230
|
+
expect(store.containerSize('f1')).toEqual(
|
|
231
|
+
frameContentSize({ hasChildren: false, initiatives: 0 }),
|
|
232
|
+
)
|
|
218
233
|
})
|
|
219
234
|
|
|
220
|
-
it('
|
|
235
|
+
it('reserves the same footprint for a new frame that a new frame will render at', () => {
|
|
236
|
+
// The drift this caught: `EMPTY_FRAME_SIZE` is what a placement decision reserves BEFORE the
|
|
237
|
+
// block exists, so it cannot measure the frame and has to predict it. A hand-copied pair
|
|
238
|
+
// went stale when the floor changed underneath it, and every new service was then dropped
|
|
239
|
+
// on top of a neighbour it had been placed to clear.
|
|
240
|
+
store.hydrate([frame('f1')])
|
|
241
|
+
expect(store.containerSize('f1')).toEqual(EMPTY_FRAME_SIZE)
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it('does not grow with task count, however many tasks and wherever they sat', () => {
|
|
245
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
246
|
+
const oneTask = store.containerSize('f1')
|
|
247
|
+
|
|
221
248
|
store.hydrate([
|
|
222
249
|
frame('f1'),
|
|
223
|
-
moduleBlock('m1', 'f1', { position: { x:
|
|
250
|
+
moduleBlock('m1', 'f1', { position: { x: 400, y: 300 } }),
|
|
224
251
|
task('t1', 'm1', { position: { x: 300, y: 200 } }),
|
|
252
|
+
// A position far outside the old content extent: it used to stretch the frame to reach
|
|
253
|
+
// it, and now means nothing at all, because a task no longer renders at coordinates.
|
|
254
|
+
task('t2', 'f1', { position: { x: 4000, y: 9000 } }),
|
|
225
255
|
])
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
256
|
+
expect(store.containerSize('f1')).toEqual(oneTask)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('makes room for the initiative band above the lanes', () => {
|
|
260
|
+
// Initiatives are the one child still laid out by the frame itself (in a wrapping band),
|
|
261
|
+
// so they are the one thing the frame's height still has to account for.
|
|
262
|
+
store.hydrate([frame('f1'), task('t1', 'f1')])
|
|
263
|
+
const withoutBand = store.containerSize('f1').h
|
|
264
|
+
store.hydrate([frame('f1'), task('t1', 'f1'), initiativeBlock('i1', 'f1')])
|
|
265
|
+
expect(store.containerSize('f1').h).toBe(withoutBand + LANE_GEOMETRY.initiativeHeight)
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('sizes a frame holding only an initiative for lanes, since that is what it renders', () => {
|
|
269
|
+
// `BlockNode` gates the lanes on having ANY child — tasks, modules or initiatives — so a
|
|
270
|
+
// frame with an initiative and no tasks renders three (empty) lanes. A size that disagreed
|
|
271
|
+
// with what rendered is the clipping this geometry exists to prevent.
|
|
272
|
+
store.hydrate([frame('f1'), initiativeBlock('i1', 'f1')])
|
|
273
|
+
expect(store.containerSize('f1')).toEqual(
|
|
274
|
+
frameContentSize({ hasChildren: true, initiatives: 1 }),
|
|
275
|
+
)
|
|
230
276
|
})
|
|
231
277
|
|
|
232
|
-
it('
|
|
233
|
-
store.hydrate([frame('f1'), moduleBlock('m1', 'f1',
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
278
|
+
it('a module reports no canvas of its own, since it is no longer drawn as a box', () => {
|
|
279
|
+
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1')])
|
|
280
|
+
expect(store.containerSize('m1').h).toBe(0)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
it('keeps an explicitly resized frame at the size the user dragged it to', () => {
|
|
284
|
+
// The geometry is a FLOOR, not a fixed size: dragging the border still gives a reader more
|
|
285
|
+
// room, and a lane grows its scroll viewport into it rather than leaving dead canvas below.
|
|
286
|
+
store.hydrate([frame('f1', { size: { w: 2000, h: 1500 } }), task('t1', 'f1')])
|
|
287
|
+
const size = store.containerSize('f1')
|
|
288
|
+
expect(size).toEqual({ w: 2000, h: 1500 })
|
|
289
|
+
expect(laneBodyHeightIn(size, 0)).toBeGreaterThan(LANE_GEOMETRY.laneBodyHeight)
|
|
238
290
|
})
|
|
239
291
|
})
|
|
240
292
|
|
|
@@ -422,6 +474,59 @@ describe('board store optimistic rollback', () => {
|
|
|
422
474
|
// the undo move is itself non-undoable, so no second toast is queued
|
|
423
475
|
expect(actions).toHaveLength(1)
|
|
424
476
|
})
|
|
477
|
+
|
|
478
|
+
it('reparentBlock predicts the declared module the server will re-stamp', async () => {
|
|
479
|
+
// The board reads a task's PARENT for its module and falls back to the name it DECLARES (a
|
|
480
|
+
// task can name a module before the engine materialises the block on merge). So a card
|
|
481
|
+
// dragged out of a module and left still declaring it re-groups under the module it was just
|
|
482
|
+
// dragged out of. The server re-stamps the name on every reparent; the optimistic write here
|
|
483
|
+
// has to predict the same answer or the card visibly jumps when the response lands.
|
|
484
|
+
//
|
|
485
|
+
// The request never settles, so what is asserted is strictly what this store put on screen
|
|
486
|
+
// BEFORE hearing back — the window the card would otherwise spend in the wrong group.
|
|
487
|
+
vi.stubGlobal('useApi', () => ({ reparentBlock: () => new Promise(() => {}) }))
|
|
488
|
+
vi.stubGlobal('useToast', () => ({ add: () => {} }))
|
|
489
|
+
setActivePinia(createPinia())
|
|
490
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
491
|
+
const store = useBoardStore()
|
|
492
|
+
store.hydrate([
|
|
493
|
+
frame('f1'),
|
|
494
|
+
moduleBlock('m1', 'f1', { title: 'Sessions' }),
|
|
495
|
+
task('t1', 'm1', { moduleName: 'Sessions' }),
|
|
496
|
+
task('t2', 'f1'),
|
|
497
|
+
])
|
|
498
|
+
|
|
499
|
+
// Out to the service frame: the declared name goes with it, as the empty string the store
|
|
500
|
+
// maps to NULL. Left behind, it is what files the card straight back into "Sessions".
|
|
501
|
+
void store.reparentBlock('t1', 'f1', { x: 0, y: 0 })
|
|
502
|
+
expect(store.getBlock('t1')?.moduleName).toBe('')
|
|
503
|
+
|
|
504
|
+
// And in: the destination module's title, whatever the task declared before.
|
|
505
|
+
void store.reparentBlock('t2', 'm1', { x: 0, y: 0 })
|
|
506
|
+
expect(store.getBlock('t2')?.moduleName).toBe('Sessions')
|
|
507
|
+
})
|
|
508
|
+
|
|
509
|
+
it('reparentBlock restores the declared module when the move is rejected', async () => {
|
|
510
|
+
// The same rollback contract the parent and position already had: a refused move must not
|
|
511
|
+
// leave the card grouped somewhere the server never put it.
|
|
512
|
+
vi.stubGlobal('useApi', () => ({
|
|
513
|
+
reparentBlock: async () => {
|
|
514
|
+
throw new Error('nope')
|
|
515
|
+
},
|
|
516
|
+
}))
|
|
517
|
+
vi.stubGlobal('useToast', () => ({ add: () => {} }))
|
|
518
|
+
setActivePinia(createPinia())
|
|
519
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
520
|
+
const store = useBoardStore()
|
|
521
|
+
store.hydrate([
|
|
522
|
+
frame('f1'),
|
|
523
|
+
moduleBlock('m1', 'f1', { title: 'Sessions' }),
|
|
524
|
+
task('t1', 'm1', { moduleName: 'Sessions' }),
|
|
525
|
+
])
|
|
526
|
+
|
|
527
|
+
await store.reparentBlock('t1', 'f1', { x: 0, y: 0 })
|
|
528
|
+
expect(store.getBlock('t1')).toMatchObject({ parentId: 'm1', moduleName: 'Sessions' })
|
|
529
|
+
})
|
|
425
530
|
})
|
|
426
531
|
|
|
427
532
|
describe('board store deferred delete + undo', () => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { createPinia, setActivePinia } from 'pinia'
|
|
3
|
+
import { useLaneViewStore } from '~/stores/laneView'
|
|
4
|
+
|
|
5
|
+
// The preference is PERSISTED in the reader's browser, which makes the restored value
|
|
6
|
+
// untrusted input: a blob written by an older build can name a sort key this build has
|
|
7
|
+
// retired, and feeding that to the comparator lookup would throw mid-sort and take the whole
|
|
8
|
+
// board down over a stale preference. That narrowing is the thing worth pinning here.
|
|
9
|
+
|
|
10
|
+
describe('laneView store', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
setActivePinia(createPinia())
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('defaults to the per-lane smart order with no grouping', () => {
|
|
16
|
+
const store = useLaneViewStore()
|
|
17
|
+
expect(store.sortKey).toBe('smart')
|
|
18
|
+
expect(store.groupKey).toBe('none')
|
|
19
|
+
expect(store.hasOverride).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('opens with the Done lane collapsed', () => {
|
|
23
|
+
// A service with a long history would otherwise open as a wall of merged cards.
|
|
24
|
+
expect(useLaneViewStore().doneLaneCollapsed).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('falls back to the default when a restored key is not in this build vocabulary', () => {
|
|
28
|
+
const store = useLaneViewStore()
|
|
29
|
+
// Exactly what a persisted blob from an older build looks like.
|
|
30
|
+
store.storedSortKey = 'sort_by_vibes'
|
|
31
|
+
store.storedGroupKey = 'group_by_astrology'
|
|
32
|
+
expect(store.sortKey).toBe('smart')
|
|
33
|
+
expect(store.groupKey).toBe('none')
|
|
34
|
+
// …and a stale value must not read as an override, or the board would claim a preference
|
|
35
|
+
// it is not actually applying.
|
|
36
|
+
expect(store.hasOverride).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reports an override so the control survives a switch to basic mode', () => {
|
|
40
|
+
const store = useLaneViewStore()
|
|
41
|
+
store.setSortKey('severity_desc')
|
|
42
|
+
expect(store.hasOverride).toBe(true)
|
|
43
|
+
store.reset()
|
|
44
|
+
expect(store.hasOverride).toBe(false)
|
|
45
|
+
expect(store.sortKey).toBe('smart')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('treats grouping alone as an override', () => {
|
|
49
|
+
const store = useLaneViewStore()
|
|
50
|
+
store.setGroupKey('module')
|
|
51
|
+
expect(store.hasOverride).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('toggles the Done lane', () => {
|
|
55
|
+
const store = useLaneViewStore()
|
|
56
|
+
store.toggleDoneLane()
|
|
57
|
+
expect(store.doneLaneCollapsed).toBe(false)
|
|
58
|
+
store.toggleDoneLane()
|
|
59
|
+
expect(store.doneLaneCollapsed).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
isLaneGroupKey,
|
|
5
|
+
isLaneSortKey,
|
|
6
|
+
type LaneGroupKey,
|
|
7
|
+
type LaneSortKey,
|
|
8
|
+
} from '~/utils/laneSort'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How this reader wants a frame's swimlanes ordered and grouped, plus whether the Done lane
|
|
12
|
+
* is open.
|
|
13
|
+
*
|
|
14
|
+
* Per USER and per BROWSER, persisted like the interface tier and the agent tier. That is a
|
|
15
|
+
* deliberate split from the Done lane's two CAPS, which are per-workspace settings: what the
|
|
16
|
+
* board may show is a shared decision about a service's history, while the order a reader
|
|
17
|
+
* scans it in is personal and changes several times an hour. Making the order shared would
|
|
18
|
+
* mean one person's triage sweep re-arranging everyone else's board.
|
|
19
|
+
*
|
|
20
|
+
* `sortKey` defaults to `smart`, which is per-lane (see `SMART_ORDER_BY_LANE`) rather than one
|
|
21
|
+
* global order — the actionable order genuinely differs by column. The explicit keys are an
|
|
22
|
+
* OVERRIDE of that, which is why the control offering them is an advanced-tier affordance:
|
|
23
|
+
* hiding it in basic mode leaves exactly the default it would have shown.
|
|
24
|
+
*/
|
|
25
|
+
export const useLaneViewStore = defineStore(
|
|
26
|
+
'laneView',
|
|
27
|
+
() => {
|
|
28
|
+
// Stored raw and narrowed on read, the same split `uiMode` uses for `storedMode`. A setup
|
|
29
|
+
// store can only persist what it returns, so these are reachable directly; the narrowing
|
|
30
|
+
// below is what makes that safe rather than a trust assumption.
|
|
31
|
+
const storedSortKey = ref<string>('smart')
|
|
32
|
+
const storedGroupKey = ref<string>('none')
|
|
33
|
+
/**
|
|
34
|
+
* Collapsed by default. The lane's job is to prove finished work exists and give a route
|
|
35
|
+
* to it, and a service with a long history would otherwise open as a wall of merged
|
|
36
|
+
* cards nobody asked to read.
|
|
37
|
+
*/
|
|
38
|
+
const doneLaneCollapsed = ref(true)
|
|
39
|
+
|
|
40
|
+
const sortKey = computed<LaneSortKey>(() =>
|
|
41
|
+
isLaneSortKey(storedSortKey.value) ? storedSortKey.value : 'smart',
|
|
42
|
+
)
|
|
43
|
+
const groupKey = computed<LaneGroupKey>(() =>
|
|
44
|
+
isLaneGroupKey(storedGroupKey.value) ? storedGroupKey.value : 'none',
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether the reader has overridden the defaults. Drives `showOverrideField`, so a
|
|
49
|
+
* preference set in advanced mode stays visible (and clearable) after a switch to basic
|
|
50
|
+
* — the one case where hiding an override would conceal a setting the board is using.
|
|
51
|
+
*/
|
|
52
|
+
const hasOverride = computed(() => sortKey.value !== 'smart' || groupKey.value !== 'none')
|
|
53
|
+
|
|
54
|
+
function setSortKey(next: LaneSortKey) {
|
|
55
|
+
storedSortKey.value = next
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setGroupKey(next: LaneGroupKey) {
|
|
59
|
+
storedGroupKey.value = next
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function reset() {
|
|
63
|
+
storedSortKey.value = 'smart'
|
|
64
|
+
storedGroupKey.value = 'none'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toggleDoneLane() {
|
|
68
|
+
doneLaneCollapsed.value = !doneLaneCollapsed.value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
storedSortKey,
|
|
73
|
+
storedGroupKey,
|
|
74
|
+
doneLaneCollapsed,
|
|
75
|
+
sortKey,
|
|
76
|
+
groupKey,
|
|
77
|
+
hasOverride,
|
|
78
|
+
setSortKey,
|
|
79
|
+
setGroupKey,
|
|
80
|
+
reset,
|
|
81
|
+
toggleDoneLane,
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
{ persist: { pick: ['storedSortKey', 'storedGroupKey', 'doneLaneCollapsed'] } },
|
|
85
|
+
)
|
|
@@ -4,7 +4,7 @@ import { useUiStore } from '~/stores/ui'
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The expansion gate combines two independent grants (hover at any zoom, the deep zoom
|
|
7
|
-
* bands otherwise). Both `TaskPipelineMini` (what renders) and `
|
|
7
|
+
* bands otherwise). Both `TaskPipelineMini` (what renders) and `LaneTask` (what
|
|
8
8
|
* stacks on top) read `isExpanded`, so these cases pin the rule they share.
|
|
9
9
|
*
|
|
10
10
|
* Zoom is set through the ui store's raw `zoom`, the same value the board canvas writes;
|
|
@@ -8,7 +8,7 @@ import { useUiStore } from '~/stores/ui'
|
|
|
8
8
|
*
|
|
9
9
|
* Two independent grants, both written every frame by the board driver
|
|
10
10
|
* (`useTaskExpansion`) and combined HERE so the render (`TaskPipelineMini`) and the
|
|
11
|
-
* stacking (`
|
|
11
|
+
* stacking (`LaneTask`) can never disagree about which cards are expanded:
|
|
12
12
|
*
|
|
13
13
|
* - HOVER — the card under the pointer expands at ANY zoom level. Pointing at a task
|
|
14
14
|
* is asking what it is doing right now, and that answer used to be reachable only
|
|
@@ -12,6 +12,10 @@ const DEFAULTS: WorkspaceSettings = {
|
|
|
12
12
|
storeAgentContext: true,
|
|
13
13
|
publishPrVerificationReport: true,
|
|
14
14
|
artifactRetentionDays: 14,
|
|
15
|
+
// The board's Done swimlane keeps two weeks and 20 cards. Both cap what is RENDERED; a
|
|
16
|
+
// task aged out of the lane is still on the board's data and still counted in its total.
|
|
17
|
+
doneLaneMaxItems: 20,
|
|
18
|
+
doneLaneRetentionDays: 14,
|
|
15
19
|
kaizenEnabled: true,
|
|
16
20
|
delegateAgentsToRunnerPool: false,
|
|
17
21
|
inputGateMode: 'standard',
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* nodes in. Sizes are the frame's rendered pixel footprint (see
|
|
9
9
|
* {@link useBlockQueries.containerSize}).
|
|
10
10
|
*/
|
|
11
|
+
import { frameContentSize } from '~/utils/laneGeometry'
|
|
11
12
|
|
|
12
13
|
export interface Point {
|
|
13
14
|
x: number
|
|
@@ -23,11 +24,15 @@ export interface FrameRect extends Point {
|
|
|
23
24
|
export const FRAME_GAP = 48
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* Footprint of a freshly-added, empty service frame in flow-space.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* Footprint of a freshly-added, empty service frame in flow-space.
|
|
28
|
+
*
|
|
29
|
+
* DERIVED from the lane geometry rather than restated, because a placement decision is made
|
|
30
|
+
* BEFORE the block exists and so cannot measure it: the numbers here and the ones the frame
|
|
31
|
+
* renders at have to be the same numbers, or a new service is dropped on top of a neighbour it
|
|
32
|
+
* was placed to clear. A hand-copied pair went stale exactly that way when the frame's floor
|
|
33
|
+
* changed underneath it.
|
|
29
34
|
*/
|
|
30
|
-
export const EMPTY_FRAME_SIZE = {
|
|
35
|
+
export const EMPTY_FRAME_SIZE = frameContentSize({ hasChildren: false, initiatives: 0 })
|
|
31
36
|
|
|
32
37
|
/**
|
|
33
38
|
* Footprint of an epic grouping node in flow-space. Epics are top-level board nodes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { frameContentSize, laneBodyHeightIn, LANE_GEOMETRY } from './laneGeometry'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* These pin the RELATION between the two derivations, not their pixel arithmetic. The numbers are
|
|
6
|
+
* `LANE_GEOMETRY`'s to change; what must never change is that a frame sized by one of them hands
|
|
7
|
+
* the other back exactly what it reserved, because that is the agreement three consumers depend on
|
|
8
|
+
* (the frame's floor, what `FrameSwimlanes` renders into, and the spot placement reserves for a
|
|
9
|
+
* frame that does not exist yet).
|
|
10
|
+
*/
|
|
11
|
+
describe('frameContentSize', () => {
|
|
12
|
+
it('sizes a service with nothing in it to its "add the first task" panel, not to lanes', () => {
|
|
13
|
+
// An empty service renders no lanes at all, so reserving lane-sized space for it would leave
|
|
14
|
+
// the frame more than twice as tall as the one thing inside it — and push its neighbours that
|
|
15
|
+
// much further away, since placement clears frames by their reserved footprint.
|
|
16
|
+
const empty = frameContentSize({ hasChildren: false, initiatives: 0 })
|
|
17
|
+
expect(empty).toEqual({
|
|
18
|
+
w: LANE_GEOMETRY.emptyFrameWidth,
|
|
19
|
+
h: LANE_GEOMETRY.emptyFrameHeight,
|
|
20
|
+
})
|
|
21
|
+
expect(empty.h).toBeLessThan(frameContentSize({ hasChildren: true, initiatives: 0 }).h)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('does not grow with anything except the initiative band', () => {
|
|
25
|
+
// The whole point of the lanes: a lane scrolls rather than growing, so nothing about a
|
|
26
|
+
// service's task count reaches this function. Initiatives are the one child the frame still
|
|
27
|
+
// lays out itself, so they are the one thing its height still answers to.
|
|
28
|
+
const base = frameContentSize({ hasChildren: true, initiatives: 0 })
|
|
29
|
+
expect(frameContentSize({ hasChildren: true, initiatives: 1 })).toEqual({
|
|
30
|
+
w: base.w,
|
|
31
|
+
h: base.h + LANE_GEOMETRY.initiativeHeight,
|
|
32
|
+
})
|
|
33
|
+
// A second row only once the first is full, whatever that width happens to allow.
|
|
34
|
+
const perRow = Math.floor(LANE_GEOMETRY.canvasWidth / LANE_GEOMETRY.initiativeWidth)
|
|
35
|
+
expect(frameContentSize({ hasChildren: true, initiatives: perRow }).h).toBe(
|
|
36
|
+
base.h + LANE_GEOMETRY.initiativeHeight,
|
|
37
|
+
)
|
|
38
|
+
expect(frameContentSize({ hasChildren: true, initiatives: perRow + 1 }).h).toBe(
|
|
39
|
+
base.h + 2 * LANE_GEOMETRY.initiativeHeight,
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('laneBodyHeightIn', () => {
|
|
45
|
+
it('hands a frame at its floor size exactly the lane body that floor was computed from', () => {
|
|
46
|
+
// The round trip that keeps the lanes from being clipped by their own frame. Asserted for a
|
|
47
|
+
// frame with an initiative band too, since the band is the term the two have to agree about.
|
|
48
|
+
for (const initiatives of [0, 1, 5]) {
|
|
49
|
+
const floor = frameContentSize({ hasChildren: true, initiatives })
|
|
50
|
+
expect(laneBodyHeightIn(floor, initiatives)).toBe(LANE_GEOMETRY.laneBodyHeight)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('gives a dragged-taller frame the whole extra height', () => {
|
|
55
|
+
// The contract `LANE_GEOMETRY` states and `containerSize` implements: the geometry is a FLOOR,
|
|
56
|
+
// and a reader who wants more room drags the border. The lanes kept a constant height before
|
|
57
|
+
// this, so the extra space was dead canvas below them and the gesture appeared to do nothing.
|
|
58
|
+
const floor = frameContentSize({ hasChildren: true, initiatives: 0 })
|
|
59
|
+
const dragged = { w: floor.w, h: floor.h + 300 }
|
|
60
|
+
expect(laneBodyHeightIn(dragged, 0)).toBe(LANE_GEOMETRY.laneBodyHeight + 300)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('never returns less than the floor lane body, whatever it is handed', () => {
|
|
64
|
+
// A frame cannot be dragged below its floor, so this is only reachable by a caller measuring
|
|
65
|
+
// one mid-layout; collapsing a lane to nothing (or a negative height) is never the answer.
|
|
66
|
+
expect(laneBodyHeightIn({ w: 100, h: 0 }, 0)).toBe(LANE_GEOMETRY.laneBodyHeight)
|
|
67
|
+
expect(laneBodyHeightIn({ w: 100, h: 40 }, 12)).toBe(LANE_GEOMETRY.laneBodyHeight)
|
|
68
|
+
})
|
|
69
|
+
})
|