@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.
Files changed (69) hide show
  1. package/README.md +158 -4
  2. package/app/components/board/LaneViewControl.vue +87 -0
  3. package/app/components/board/nodes/BlockNode.vue +31 -11
  4. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  5. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  6. package/app/components/board/nodes/LaneGroup.vue +93 -0
  7. package/app/components/board/nodes/LaneTask.vue +66 -0
  8. package/app/components/board/nodes/TaskCard.vue +16 -2
  9. package/app/components/board/nodes/TaskLane.vue +82 -0
  10. package/app/components/layout/BoardToolbar.vue +4 -0
  11. package/app/components/layout/CommandBar.vue +8 -1
  12. package/app/components/layout/RolePrompt.vue +75 -0
  13. package/app/components/layout/SideBar.vue +22 -13
  14. package/app/components/layout/UiRoleSwitcher.vue +73 -0
  15. package/app/components/panels/InspectorPanel.vue +15 -2
  16. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  17. package/app/components/settings/WorkspaceSettingsPanel.vue +59 -0
  18. package/app/composables/useBlockDrag.ts +47 -17
  19. package/app/composables/useBlockQueries.ts +27 -24
  20. package/app/composables/useFrameLanes.ts +177 -0
  21. package/app/composables/useNavContributions.ts +3 -0
  22. package/app/composables/useTaskExpansion.ts +1 -1
  23. package/app/docs/consumer-extensions.md +20 -6
  24. package/app/modular/external-tools.spec.ts +0 -45
  25. package/app/modular/external-tools.ts +12 -23
  26. package/app/modular/nav-contributions.spec.ts +176 -17
  27. package/app/modular/nav-contributions.ts +106 -23
  28. package/app/modular/nav-gates.ts +11 -2
  29. package/app/modular/registry.spec.ts +1 -0
  30. package/app/modular/tutorial-tours.spec.ts +5 -3
  31. package/app/modular/tutorial-tours.ts +53 -8
  32. package/app/pages/index.vue +36 -7
  33. package/app/stores/board/placement.ts +7 -0
  34. package/app/stores/board.spec.ts +119 -14
  35. package/app/stores/laneView.spec.ts +61 -0
  36. package/app/stores/laneView.ts +85 -0
  37. package/app/stores/launchPrompt.ts +63 -0
  38. package/app/stores/taskExpansion.spec.ts +1 -1
  39. package/app/stores/taskExpansion.ts +1 -1
  40. package/app/stores/tutorial.ts +4 -4
  41. package/app/stores/uiMode.spec.ts +11 -0
  42. package/app/stores/uiMode.ts +14 -2
  43. package/app/stores/uiRole.spec.ts +185 -0
  44. package/app/stores/uiRole.ts +86 -0
  45. package/app/stores/workspaceSettings.ts +4 -0
  46. package/app/utils/framePlacement.ts +9 -4
  47. package/app/utils/laneGeometry.spec.ts +69 -0
  48. package/app/utils/laneGeometry.ts +104 -0
  49. package/app/utils/laneSort.spec.ts +236 -0
  50. package/app/utils/laneSort.ts +306 -0
  51. package/app/utils/swimlanes.spec.ts +259 -0
  52. package/app/utils/swimlanes.ts +355 -0
  53. package/app/utils/uiMode.spec.ts +12 -0
  54. package/app/utils/uiMode.ts +24 -6
  55. package/app/utils/uiRole.ts +123 -0
  56. package/i18n/locales/de.json +104 -3
  57. package/i18n/locales/en.json +110 -3
  58. package/i18n/locales/es.json +104 -3
  59. package/i18n/locales/fr.json +104 -3
  60. package/i18n/locales/he.json +104 -3
  61. package/i18n/locales/it.json +104 -3
  62. package/i18n/locales/ja.json +104 -3
  63. package/i18n/locales/pl.json +104 -3
  64. package/i18n/locales/tr.json +104 -3
  65. package/i18n/locales/uk.json +104 -3
  66. package/package.json +2 -2
  67. package/app/components/board/nodes/DraggableTask.vue +0 -58
  68. package/app/components/board/nodes/ModuleFrame.vue +0 -73
  69. 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
- <UInput
29
- v-model="block.moduleName"
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
- * (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
+ }
@@ -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 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)
@@ -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? }` | the three shells via `useNavContributions` |
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? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
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
- A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
73
- (the shipped default) exactly as it does for the first-party destinations: see
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; the flag is independent of `gate`, so both must pass for the item to render.
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 `advanced`** work exactly as on a `nav` entry; both must pass.
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