@cat-factory/app 0.274.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 +158 -4
- package/app/components/board/LaneViewControl.vue +87 -0
- package/app/components/board/nodes/BlockNode.vue +31 -11
- 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/layout/CommandBar.vue +8 -1
- package/app/components/layout/RolePrompt.vue +75 -0
- package/app/components/layout/SideBar.vue +22 -13
- package/app/components/layout/UiRoleSwitcher.vue +73 -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/useNavContributions.ts +3 -0
- package/app/composables/useTaskExpansion.ts +1 -1
- package/app/docs/consumer-extensions.md +20 -6
- package/app/modular/external-tools.spec.ts +0 -45
- package/app/modular/external-tools.ts +12 -23
- package/app/modular/nav-contributions.spec.ts +176 -17
- package/app/modular/nav-contributions.ts +106 -23
- package/app/modular/nav-gates.ts +11 -2
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/tutorial-tours.spec.ts +5 -3
- package/app/modular/tutorial-tours.ts +53 -8
- package/app/pages/index.vue +36 -7
- 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/launchPrompt.ts +63 -0
- package/app/stores/taskExpansion.spec.ts +1 -1
- package/app/stores/taskExpansion.ts +1 -1
- package/app/stores/tutorial.ts +4 -4
- package/app/stores/uiMode.spec.ts +11 -0
- package/app/stores/uiMode.ts +14 -2
- package/app/stores/uiRole.spec.ts +185 -0
- package/app/stores/uiRole.ts +86 -0
- 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/app/utils/uiMode.spec.ts +12 -0
- package/app/utils/uiMode.ts +24 -6
- package/app/utils/uiRole.ts +123 -0
- package/i18n/locales/de.json +104 -3
- package/i18n/locales/en.json +110 -3
- package/i18n/locales/es.json +104 -3
- package/i18n/locales/fr.json +104 -3
- package/i18n/locales/he.json +104 -3
- package/i18n/locales/it.json +104 -3
- package/i18n/locales/ja.json +104 -3
- package/i18n/locales/pl.json +104 -3
- package/i18n/locales/tr.json +104 -3
- package/i18n/locales/uk.json +104 -3
- package/package.json +2 -2
- package/app/components/board/nodes/DraggableTask.vue +0 -58
- package/app/components/board/nodes/ModuleFrame.vue +0 -73
- package/app/stores/tutorial.prompt.ts +0 -59
|
@@ -7,8 +7,69 @@ const props = defineProps<{ block: Block }>()
|
|
|
7
7
|
|
|
8
8
|
const board = useBoardStore()
|
|
9
9
|
const fragments = useFragmentsStore()
|
|
10
|
+
const toast = useToast()
|
|
10
11
|
const { t } = useI18n()
|
|
11
12
|
|
|
13
|
+
// ---- module assignment -----------------------------------------------------
|
|
14
|
+
// This is the module-assignment route that does NOT depend on how the board is currently
|
|
15
|
+
// grouped. Dragging a card onto a module's group header works only while the reader has
|
|
16
|
+
// grouping set to `module`, and module sub-frames no longer render as boxes to drop onto, so
|
|
17
|
+
// without this the only way to move a task into a module would be to first change a view
|
|
18
|
+
// preference. It writes the same two things a drag does: the declared `moduleName` and, when the
|
|
19
|
+
// module block already exists, the structural parent.
|
|
20
|
+
//
|
|
21
|
+
// The field used to be a free-text `UInput` bound with `v-model="block.moduleName"`, which
|
|
22
|
+
// mutated the cached store object and never called `updateBlock` — so a module typed here was
|
|
23
|
+
// silently discarded on the next board refresh. Now that module grouping is a first-class board
|
|
24
|
+
// affordance, a field that looks like it assigns a module and does not would read as the grouping
|
|
25
|
+
// being broken.
|
|
26
|
+
const service = computed(() => board.serviceOf(props.block))
|
|
27
|
+
|
|
28
|
+
/** The modules the enclosing service has materialised, as picker options. */
|
|
29
|
+
const moduleOptions = computed(() => {
|
|
30
|
+
const frame = service.value
|
|
31
|
+
const existing = frame ? board.modulesOf(frame.id).map((m) => m.title) : []
|
|
32
|
+
// A task can DECLARE a module the engine has not created a block for yet (it materialises one
|
|
33
|
+
// on merge), so the task's own value has to be offerable even when no block carries that name,
|
|
34
|
+
// or opening the picker would silently drop it.
|
|
35
|
+
const own = props.block.moduleName?.trim()
|
|
36
|
+
const names = own && !existing.includes(own) ? [...existing, own] : existing
|
|
37
|
+
return [
|
|
38
|
+
{ label: t('inspector.structure.moduleNone'), value: '' },
|
|
39
|
+
...names.map((n) => ({ label: n, value: n })),
|
|
40
|
+
]
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const selectedModule = computed(() => props.block.moduleName?.trim() ?? '')
|
|
44
|
+
|
|
45
|
+
async function setModule(name: string) {
|
|
46
|
+
const previous = selectedModule.value
|
|
47
|
+
if (name === previous) return
|
|
48
|
+
try {
|
|
49
|
+
// The declared module is what the engine reads when it materialises the module block on
|
|
50
|
+
// merge, so it is written first and is authoritative. "No module" sends the EMPTY STRING,
|
|
51
|
+
// which is how `updateBlock` spells a clear: `undefined` is dropped by `JSON.stringify`, so
|
|
52
|
+
// it reached the server as an empty patch and the response then restored the old value.
|
|
53
|
+
await board.updateBlock(props.block.id, { moduleName: name })
|
|
54
|
+
|
|
55
|
+
// When a block for that module already exists, move the task under it now rather than waiting
|
|
56
|
+
// for a merge, so the board's grouping matches what was just chosen.
|
|
57
|
+
const frame = service.value
|
|
58
|
+
const target = frame
|
|
59
|
+
? name
|
|
60
|
+
? board.modulesOf(frame.id).find((m) => m.title === name)
|
|
61
|
+
: frame
|
|
62
|
+
: undefined
|
|
63
|
+
if (target && target.id !== props.block.parentId) {
|
|
64
|
+
await board.reparentBlock(props.block.id, target.id, { x: 0, y: 0 })
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// `updateBlock`/`reparentBlock` already roll back and toast their own failures; this catch
|
|
68
|
+
// exists so a rejected reparent cannot leave the await chain unhandled.
|
|
69
|
+
// silent-catch-ok: both mutations report their own failure to the user.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
12
73
|
// ---- best-practice prompt fragments ----------------------------------------
|
|
13
74
|
// The task's OWN selection (seeded from its service at creation, then editable per task). The
|
|
14
75
|
// shared <FragmentSelector> renders the picker; a change persists via updateBlock.
|
|
@@ -25,12 +86,18 @@ function setFragments(ids: string[]) {
|
|
|
25
86
|
<div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
26
87
|
{{ t('inspector.structure.module') }}
|
|
27
88
|
</div>
|
|
28
|
-
<
|
|
29
|
-
|
|
89
|
+
<USelectMenu
|
|
90
|
+
:model-value="selectedModule"
|
|
91
|
+
:items="moduleOptions"
|
|
92
|
+
value-key="value"
|
|
30
93
|
size="sm"
|
|
31
94
|
class="w-full"
|
|
32
|
-
:placeholder="t('inspector.structure.modulePlaceholder')"
|
|
33
95
|
icon="i-lucide-package"
|
|
96
|
+
:create-item="true"
|
|
97
|
+
:placeholder="t('inspector.structure.modulePlaceholder')"
|
|
98
|
+
data-testid="task-module-select"
|
|
99
|
+
@update:model-value="setModule"
|
|
100
|
+
@create="setModule"
|
|
34
101
|
/>
|
|
35
102
|
<p class="mt-1 text-[11px] leading-snug text-slate-500">
|
|
36
103
|
{{ t('inspector.structure.moduleHint') }}
|
|
@@ -197,6 +197,9 @@ const draft = reactive({
|
|
|
197
197
|
storeAgentContext: true,
|
|
198
198
|
publishPrVerificationReport: true,
|
|
199
199
|
artifactRetentionDays: 14,
|
|
200
|
+
doneLaneMaxItems: 20,
|
|
201
|
+
doneLaneRetentionEnabled: true,
|
|
202
|
+
doneLaneRetentionDays: 14 as number,
|
|
200
203
|
kaizenEnabled: true,
|
|
201
204
|
allowInitiatorPat: true,
|
|
202
205
|
inputGateMode: 'standard' as InputGateMode,
|
|
@@ -218,6 +221,12 @@ function hydrate() {
|
|
|
218
221
|
draft.storeAgentContext = s.storeAgentContext
|
|
219
222
|
draft.publishPrVerificationReport = s.publishPrVerificationReport
|
|
220
223
|
draft.artifactRetentionDays = s.artifactRetentionDays
|
|
224
|
+
draft.doneLaneMaxItems = s.doneLaneMaxItems
|
|
225
|
+
// Nullable (null ⇒ no age cap), so a checkbox is derived from whether a value is stored
|
|
226
|
+
// and the number input keeps a sensible starting value to switch back on with — the same
|
|
227
|
+
// shape the nullable review-friction triggers below use.
|
|
228
|
+
draft.doneLaneRetentionEnabled = s.doneLaneRetentionDays != null
|
|
229
|
+
draft.doneLaneRetentionDays = s.doneLaneRetentionDays ?? 14
|
|
221
230
|
draft.kaizenEnabled = s.kaizenEnabled
|
|
222
231
|
draft.allowInitiatorPat = s.allowInitiatorPat
|
|
223
232
|
draft.inputGateMode = s.inputGateMode
|
|
@@ -276,6 +285,8 @@ async function save() {
|
|
|
276
285
|
storeAgentContext: draft.storeAgentContext,
|
|
277
286
|
publishPrVerificationReport: draft.publishPrVerificationReport,
|
|
278
287
|
artifactRetentionDays: draft.artifactRetentionDays,
|
|
288
|
+
doneLaneMaxItems: draft.doneLaneMaxItems,
|
|
289
|
+
doneLaneRetentionDays: draft.doneLaneRetentionEnabled ? draft.doneLaneRetentionDays : null,
|
|
279
290
|
kaizenEnabled: draft.kaizenEnabled,
|
|
280
291
|
allowInitiatorPat: draft.allowInitiatorPat,
|
|
281
292
|
inputGateMode: draft.inputGateMode,
|
|
@@ -540,6 +551,54 @@ async function save() {
|
|
|
540
551
|
</label>
|
|
541
552
|
</section>
|
|
542
553
|
|
|
554
|
+
<!-- What the board's Done swimlane keeps in view -->
|
|
555
|
+
<section class="space-y-2">
|
|
556
|
+
<h3 class="text-sm font-semibold text-slate-200">
|
|
557
|
+
{{ t('settings.workspaceSettings.doneLane.heading') }}
|
|
558
|
+
</h3>
|
|
559
|
+
<p class="text-[11px] text-slate-400">
|
|
560
|
+
{{ t('settings.workspaceSettings.doneLane.body') }}
|
|
561
|
+
</p>
|
|
562
|
+
<label class="block w-48">
|
|
563
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
564
|
+
{{ t('settings.workspaceSettings.doneLane.maxItems') }}
|
|
565
|
+
</span>
|
|
566
|
+
<UInput
|
|
567
|
+
v-model.number="draft.doneLaneMaxItems"
|
|
568
|
+
type="number"
|
|
569
|
+
:min="0"
|
|
570
|
+
:max="500"
|
|
571
|
+
size="sm"
|
|
572
|
+
data-testid="done-lane-max-items"
|
|
573
|
+
/>
|
|
574
|
+
</label>
|
|
575
|
+
<p v-if="draft.doneLaneMaxItems === 0" class="text-[11px] text-slate-500">
|
|
576
|
+
{{ t('settings.workspaceSettings.doneLane.zeroHint') }}
|
|
577
|
+
</p>
|
|
578
|
+
<label class="flex items-center gap-2">
|
|
579
|
+
<UCheckbox v-model="draft.doneLaneRetentionEnabled" size="sm" />
|
|
580
|
+
<span class="text-[11px] text-slate-300">{{
|
|
581
|
+
t('settings.workspaceSettings.doneLane.ageToggle')
|
|
582
|
+
}}</span>
|
|
583
|
+
</label>
|
|
584
|
+
<label v-if="draft.doneLaneRetentionEnabled" class="block w-48">
|
|
585
|
+
<span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
|
|
586
|
+
{{ t('settings.workspaceSettings.doneLane.days') }}
|
|
587
|
+
</span>
|
|
588
|
+
<UInput
|
|
589
|
+
v-model.number="draft.doneLaneRetentionDays"
|
|
590
|
+
type="number"
|
|
591
|
+
:min="1"
|
|
592
|
+
:max="3650"
|
|
593
|
+
size="sm"
|
|
594
|
+
data-testid="done-lane-retention-days"
|
|
595
|
+
/>
|
|
596
|
+
</label>
|
|
597
|
+
<p class="text-[11px] text-slate-500">
|
|
598
|
+
{{ t('settings.workspaceSettings.doneLane.hidesOnlyHint') }}
|
|
599
|
+
</p>
|
|
600
|
+
</section>
|
|
601
|
+
|
|
543
602
|
<!-- Run credential: the App installation vs. the initiator's own token -->
|
|
544
603
|
<section class="space-y-2">
|
|
545
604
|
<h3 class="text-sm font-semibold text-slate-200">
|
|
@@ -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
|
+
}
|
|
@@ -66,6 +66,9 @@ export function useNavContributions() {
|
|
|
66
66
|
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
67
67
|
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
68
68
|
toggleUiMode: () => useUiModeStore().toggleMode(),
|
|
69
|
+
// The QUESTION, not a toggle: with three roles there is no unambiguous "next one", and the
|
|
70
|
+
// prompt is the one surface that states what each role gives you before you pick it.
|
|
71
|
+
chooseRole: () => useUiRoleStore().openPrompt(),
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
/** Run a contribution's action (consumer `run` closure wins over the id map). */
|
|
@@ -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)
|
|
@@ -61,18 +61,29 @@ export default defineNuxtPlugin(() => {
|
|
|
61
61
|
| Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
|
|
62
62
|
| Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
|
|
63
63
|
| Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, defaultFragmentIds?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
|
|
64
|
-
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }`
|
|
64
|
+
| Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, intake?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
|
|
65
65
|
| Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
|
|
66
66
|
| Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
|
|
67
|
-
| External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }`
|
|
67
|
+
| External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, intake?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
|
|
68
68
|
| Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
|
|
69
69
|
| Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
|
|
70
70
|
| Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
Beyond `gate`, a destination answers to two narrowing axes, both applied to your entries exactly
|
|
73
|
+
as they are to the first-party ones. All three are independent, and all three must pass.
|
|
74
|
+
|
|
75
|
+
`advanced: true` hides an entry in **basic** interface mode, the shipped default: see
|
|
74
76
|
[the layer README](../../README.md#interface-modes-basic--advanced). Use it for a power-user
|
|
75
|
-
destination
|
|
77
|
+
destination.
|
|
78
|
+
|
|
79
|
+
`intake: true` keeps an entry for a role narrowed to the intake surface, today `designer`: see
|
|
80
|
+
[Roles](../../README.md#roles-engineer--product-manager--designer). This one is opt-IN, so an
|
|
81
|
+
entry that says nothing is offered to the full-surface roles only. Declare it where your
|
|
82
|
+
destination is somewhere work comes IN from rather than somewhere the platform is configured; a
|
|
83
|
+
design-handoff console qualifies, an admin panel does not.
|
|
84
|
+
|
|
85
|
+
Both flags live on `NavGatedContribution`, which an external tool extends too, so a tool answers
|
|
86
|
+
the same axes as a `nav` entry and there is one predicate (`navItemVisible`) deciding both.
|
|
76
87
|
|
|
77
88
|
### Run-detail windows (`resultViews` + `agentKinds`)
|
|
78
89
|
|
|
@@ -153,7 +164,10 @@ workspaceMetadataFields: [{ key: 'gameId', label: 'Game id', placeholder: 'zork'
|
|
|
153
164
|
reason (`resolver-failed`) with the cause logged to the console: the sidebar, the palette and
|
|
154
165
|
the toolbar all render from one catalog, so an uncaught throw would otherwise blank all three.
|
|
155
166
|
Do not rely on it: `requiredMetadata` is how you say a field must be there.
|
|
156
|
-
- **`gate` and `
|
|
167
|
+
- **`gate`, `advanced` and `intake`** work exactly as on a `nav` entry, and for the same reason:
|
|
168
|
+
a tool is projected onto a nav contribution and filtered by the same predicate. All must pass,
|
|
169
|
+
and `intake` defaults the same way, so a registered application is dropped for a narrowed role
|
|
170
|
+
until you say it belongs there.
|
|
157
171
|
|
|
158
172
|
**The metadata half** is a deployment-declared FIELD list (here) whose VALUES are per workspace,
|
|
159
173
|
typed in under _Workspace settings → Metadata_ and persisted on the workspace settings row. The
|