@cat-factory/app 0.275.0 → 0.277.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +89 -2
  2. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +13 -1
  3. package/app/components/binaryOutput/BinaryOutputReport.vue +15 -1
  4. package/app/components/binaryOutput/StoredAssetView.vue +134 -0
  5. package/app/components/board/AddTaskModal.vue +9 -4
  6. package/app/components/board/LaneViewControl.vue +87 -0
  7. package/app/components/board/nodes/BlockNode.vue +24 -10
  8. package/app/components/board/nodes/FrameSwimlanes.vue +139 -0
  9. package/app/components/board/nodes/InitiativeCard.vue +9 -28
  10. package/app/components/board/nodes/LaneGroup.vue +93 -0
  11. package/app/components/board/nodes/LaneTask.vue +66 -0
  12. package/app/components/board/nodes/TaskCard.vue +16 -2
  13. package/app/components/board/nodes/TaskLane.vue +82 -0
  14. package/app/components/layout/BoardToolbar.vue +4 -0
  15. package/app/components/palettes/PipelinePurposeSelect.vue +1 -0
  16. package/app/components/panels/InspectorPanel.vue +15 -2
  17. package/app/components/panels/inspector/TaskStructure.vue +70 -3
  18. package/app/components/settings/WorkspaceSettingsPanel.vue +61 -1
  19. package/app/composables/api/visualConfirm.ts +11 -2
  20. package/app/composables/useArtifactBlobs.spec.ts +76 -0
  21. package/app/composables/useArtifactBlobs.ts +25 -4
  22. package/app/composables/useBlockDrag.ts +47 -17
  23. package/app/composables/useBlockQueries.ts +27 -24
  24. package/app/composables/useFrameLanes.ts +177 -0
  25. package/app/composables/useTaskExpansion.ts +1 -1
  26. package/app/stores/board/placement.ts +7 -0
  27. package/app/stores/board.spec.ts +119 -14
  28. package/app/stores/laneView.spec.ts +61 -0
  29. package/app/stores/laneView.ts +85 -0
  30. package/app/stores/taskExpansion.spec.ts +1 -1
  31. package/app/stores/taskExpansion.ts +1 -1
  32. package/app/stores/workspaceSettings.ts +4 -0
  33. package/app/utils/binaryCandidates.ts +19 -1
  34. package/app/utils/binaryOutput.ts +13 -0
  35. package/app/utils/catalog.ts +6 -0
  36. package/app/utils/framePlacement.ts +9 -4
  37. package/app/utils/laneGeometry.spec.ts +69 -0
  38. package/app/utils/laneGeometry.ts +104 -0
  39. package/app/utils/laneSort.spec.ts +236 -0
  40. package/app/utils/laneSort.ts +306 -0
  41. package/app/utils/swimlanes.spec.ts +259 -0
  42. package/app/utils/swimlanes.ts +355 -0
  43. package/i18n/locales/de.json +91 -5
  44. package/i18n/locales/en.json +91 -5
  45. package/i18n/locales/es.json +91 -5
  46. package/i18n/locales/fr.json +91 -5
  47. package/i18n/locales/he.json +91 -5
  48. package/i18n/locales/it.json +91 -5
  49. package/i18n/locales/ja.json +91 -5
  50. package/i18n/locales/pl.json +91 -5
  51. package/i18n/locales/tr.json +91 -5
  52. package/i18n/locales/uk.json +91 -5
  53. package/package.json +2 -2
  54. package/app/components/board/nodes/DraggableTask.vue +0 -58
  55. package/app/components/board/nodes/ModuleFrame.vue +0 -73
@@ -9,10 +9,14 @@
9
9
  // on the plan-approval gate (or on an agent-raised decision) offers the button that
10
10
  // opens the window resolving it, instead of leaving the card on a spinning "Run
11
11
  // planning" whose only route in was the inspector's execution panel.
12
- // The tracker button opens the dedicated window directly. Draggable within its
13
- // frame like a task card.
12
+ // The tracker button opens the dedicated window directly.
13
+ //
14
+ // Laid out in a wrapping band above the frame's task swimlanes, not at coordinates. It used to be
15
+ // free-positioned inside the frame's canvas beside the task cards, and lost that along with them
16
+ // when tasks moved into lanes: with the canvas gone there is nothing left for an initiative's
17
+ // coordinates to be relative to, and its drag handle only ever moved it within a canvas nothing
18
+ // renders now. An initiative is still a first-class board block with its own inspector.
14
19
  import type { InitiativeStatus } from '~/types/domain'
15
- import { useBlockDrag } from '~/composables/useBlockDrag'
16
20
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
17
21
  import {
18
22
  INITIATIVE_ATTENTION_ICONS,
@@ -27,7 +31,6 @@ const board = useBoardStore()
27
31
  const initiatives = useInitiativesStore()
28
32
  const ui = useUiStore()
29
33
  const { t } = useI18n()
30
- const { draggingId, startDrag } = useBlockDrag()
31
34
 
32
35
  const block = computed(() => board.getBlock(props.blockId))
33
36
  const initiative = computed(() => initiatives.forBlock(props.blockId))
@@ -56,36 +59,14 @@ const {
56
59
  function select() {
57
60
  ui.select(props.blockId)
58
61
  }
59
- function onHandle(e: PointerEvent) {
60
- if (block.value) startDrag(block.value, e)
61
- }
62
62
  </script>
63
63
 
64
64
  <template>
65
- <div
66
- v-if="block"
67
- class="absolute w-[230px]"
68
- :style="{
69
- left: block.position.x + 'px',
70
- top: block.position.y + 'px',
71
- zIndex: draggingId === blockId ? 60 : 10,
72
- pointerEvents: draggingId === blockId ? 'none' : undefined,
73
- }"
74
- >
75
- <div
76
- class="nodrag nopan flex cursor-grab touch-none items-center justify-center rounded-t-lg border border-b-0 border-indigo-800/60 bg-indigo-950/60 py-px active:cursor-grabbing pointer-coarse:py-2"
77
- :title="t('board.frame.dragTask')"
78
- @pointerdown="onHandle"
79
- >
80
- <UIcon
81
- name="i-lucide-grip-horizontal"
82
- class="h-3 w-3 text-indigo-400/60 pointer-coarse:h-5 pointer-coarse:w-5"
83
- />
84
- </div>
65
+ <div v-if="block" class="w-[230px]">
85
66
  <div
86
67
  data-testid="initiative-card"
87
68
  :data-status="status"
88
- class="cursor-pointer rounded-b-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
69
+ class="cursor-pointer rounded-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
89
70
  :class="[
90
71
  selected ? 'ring-2 ring-indigo-400/60' : '',
91
72
  awaitingAnswers || attention ? 'board-pulse' : '',
@@ -0,0 +1,93 @@
1
+ <script setup lang="ts">
2
+ import LaneTask from './LaneTask.vue'
3
+ import { MODULE_META, taskTypeMeta } from '~/utils/catalog'
4
+ import { LANE_GEOMETRY } from '~/utils/laneGeometry'
5
+ import type { LaneGroup, LaneGroupKey } from '~/utils/laneSort'
6
+ import { LANE_REASON_LABEL_KEYS, type LaneReason } from '~/utils/swimlanes'
7
+
8
+ /**
9
+ * One labelled run of cards inside a lane.
10
+ *
11
+ * The WRAPPER carries `data-drop-zone`, not the header, so dropping anywhere in a module's group
12
+ * — on its title or on a card already in it — means "into that module". With the zone on the
13
+ * header alone, a drop onto one of the group's own cards would fall through to the lane's frame
14
+ * zone and reparent the card OUT of the module it was dropped into, which is the opposite of
15
+ * what the gesture said.
16
+ *
17
+ * That drop target is also why module grouping matters beyond presentation: module sub-frames no
18
+ * longer render as boxes, so this is the board's drag route into a module. The inspector's module
19
+ * picker is the route that does not depend on the current grouping.
20
+ */
21
+ const props = withDefaults(
22
+ defineProps<{
23
+ group: LaneGroup
24
+ groupKey: LaneGroupKey
25
+ /** The enclosing service frame, so the catch-all group can be the way back OUT of a module. */
26
+ frameId: string
27
+ /**
28
+ * How the group's own cards run: down a lane column (the default) or wrapped across the full
29
+ * width of the Done strip, which is what makes the opened archive a grid rather than a column.
30
+ */
31
+ layout?: 'column' | 'grid'
32
+ }>(),
33
+ { layout: 'column' },
34
+ )
35
+
36
+ const { t } = useI18n()
37
+
38
+ /**
39
+ * Which block a drop onto this group reparents into.
40
+ *
41
+ * Only meaningful while grouping BY MODULE: a named group targets its module block, and the
42
+ * catch-all ("no module") targets the frame, which is what makes dragging a card out of a module
43
+ * possible. Under any other grouping the group is not a container at all, so it declares no zone
44
+ * and drops fall through to the lane's own frame zone.
45
+ */
46
+ const dropZone = computed(() => {
47
+ if (props.groupKey !== 'module') return null
48
+ return props.group.label == null ? props.frameId : props.group.id
49
+ })
50
+
51
+ /** Group labels are DATA (a module name, a type, a reason), so each kind is rendered as itself. */
52
+ const label = computed(() => {
53
+ const raw = props.group.label
54
+ // `none` grouping renders no header at all, so it has no catch-all label to name.
55
+ if (props.groupKey === 'none') return ''
56
+ if (raw == null) return t(`board.lanes.group.catchAll.${props.groupKey}`)
57
+ if (props.groupKey === 'task_type') return taskTypeMeta(raw).label
58
+ if (props.groupKey === 'blocking_reason') {
59
+ return t(LANE_REASON_LABEL_KEYS[raw as LaneReason] ?? 'board.lanes.reason.unclassified')
60
+ }
61
+ return raw
62
+ })
63
+
64
+ const icon = computed(() => {
65
+ if (props.groupKey === 'module') return MODULE_META.icon
66
+ if (props.groupKey === 'initiative') return 'i-lucide-flag'
67
+ if (props.groupKey === 'epic') return 'i-lucide-layers'
68
+ return null
69
+ })
70
+ </script>
71
+
72
+ <template>
73
+ <div :data-drop-zone="dropZone ?? undefined" class="space-y-1.5">
74
+ <!-- `none` grouping renders no header: one unlabelled group IS the flat lane, and a header
75
+ saying "all of them" would be a row of chrome carrying no information. -->
76
+ <div
77
+ v-if="groupKey !== 'none'"
78
+ class="flex items-center gap-1 px-0.5 text-[10px] uppercase tracking-wide text-slate-500"
79
+ >
80
+ <UIcon v-if="icon" :name="icon" class="h-3 w-3 shrink-0" />
81
+ <span class="truncate" :title="label">{{ label }}</span>
82
+ <span class="ms-auto shrink-0 tabular-nums">{{ group.entries.length }}</span>
83
+ </div>
84
+ <div :class="layout === 'grid' ? 'flex flex-wrap items-start gap-2' : 'space-y-1.5'">
85
+ <LaneTask
86
+ v-for="entry in group.entries"
87
+ :key="entry.task.id"
88
+ :task-id="entry.task.id"
89
+ :style="layout === 'grid' ? { width: LANE_GEOMETRY.cardWidth + 'px' } : undefined"
90
+ />
91
+ </div>
92
+ </div>
93
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import TaskCard from './TaskCard.vue'
3
+ import { useBlockDrag } from '~/composables/useBlockDrag'
4
+
5
+ /**
6
+ * One task card in a swimlane.
7
+ *
8
+ * Replaces the old `DraggableTask`, and the difference is the whole point of the lanes: a card
9
+ * no longer carries coordinates. It sits where its lane's order puts it, so this wrapper is an
10
+ * ordinary flow item and the drag it starts is REPARENT-ONLY (`positioned: false`) — moving work
11
+ * between services, and into or out of a module, which is what a task drag was always actually
12
+ * for. Nothing is previewed while dragging, because there is nowhere to preview to.
13
+ *
14
+ * It also renders merged tasks, where `DraggableTask` returned nothing for them. That is what
15
+ * the Done lane needed: a finished task used to vanish from the board entirely, so a service's
16
+ * own history was invisible on it.
17
+ */
18
+ const props = defineProps<{ taskId: string }>()
19
+
20
+ const board = useBoardStore()
21
+ const access = useWorkspaceAccess()
22
+ const expansion = useTaskExpansionStore()
23
+ const { t } = useI18n()
24
+ const { draggingId, startDrag } = useBlockDrag()
25
+
26
+ const task = computed(() => board.getBlock(props.taskId))
27
+ const dragging = computed(() => draggingId.value === props.taskId)
28
+
29
+ // An expanded pipeline overlays its neighbours, so it must stack above the compact cards
30
+ // around it. Reads the same predicate the pipeline itself renders on.
31
+ const expanded = computed(() => expansion.isExpanded(props.taskId))
32
+
33
+ function onHandle(e: PointerEvent) {
34
+ if (task.value) startDrag(task.value, e, { reparent: true, positioned: false })
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <div
40
+ v-if="task"
41
+ class="relative"
42
+ :style="{
43
+ zIndex: dragging ? 60 : expanded ? 20 : 10,
44
+ // While this card is being dragged it must not capture hit-tests, so the drop zone
45
+ // beneath the cursor (a lane, or a module's group) resolves on release. The handle sits
46
+ // in this wrapper above the card and would otherwise mask the zone under it.
47
+ pointerEvents: dragging ? 'none' : undefined,
48
+ }"
49
+ :class="{ 'opacity-40': dragging }"
50
+ >
51
+ <!-- Drag handle. `nopan` so a start-drag from here moves the card, not the pane. Hidden
52
+ for read-only viewers, for whom the drag is a no-op anyway (see useBlockDrag). -->
53
+ <div
54
+ v-if="access.canWriteBoard.value"
55
+ class="nodrag nopan flex cursor-grab touch-none items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing pointer-coarse:py-2"
56
+ :title="t('board.frame.dragTask')"
57
+ @pointerdown="onHandle"
58
+ >
59
+ <UIcon
60
+ name="i-lucide-grip-horizontal"
61
+ class="h-3 w-3 text-slate-500 pointer-coarse:h-5 pointer-coarse:w-5"
62
+ />
63
+ </div>
64
+ <TaskCard :task-id="taskId" :class="access.canWriteBoard.value ? '!rounded-t-none' : ''" />
65
+ </div>
66
+ </template>
@@ -119,6 +119,7 @@ const prLabel = computed(() =>
119
119
  * every section says "nothing here" would teach people the surface is empty. A task marked done
120
120
  * by hand, with no pull request and no run, is that task.
121
121
  */
122
+ const laneView = useLaneViewStore()
122
123
  const outcomeReadable = computed(() => {
123
124
  const block = task.value
124
125
  if (!block) return false
@@ -136,6 +137,17 @@ const outcomeReadable = computed(() => {
136
137
  const showPrChip = computed(
137
138
  () => Boolean(pr.value) && (uiMode.isAdvanced || !outcomeReadable.value),
138
139
  )
140
+
141
+ /**
142
+ * The module chip, dropped while the swimlanes are GROUPED by module.
143
+ *
144
+ * Written as the invariant ("the card names its module wherever nothing else does") for the same
145
+ * reason `showPrChip` is: the surface that carries the other half is itself conditional, and two
146
+ * predicates that have to agree by coincidence eventually do not.
147
+ */
148
+ const showModuleChip = computed(
149
+ () => Boolean(task.value?.moduleName) && laneView.groupKey !== 'module',
150
+ )
139
151
  function openOutcome() {
140
152
  ui.openOutcome(props.taskId, task.value?.executionId ?? null)
141
153
  }
@@ -539,9 +551,11 @@ function selectTask() {
539
551
  </template>
540
552
  </div>
541
553
 
542
- <!-- structural metadata: assigned module -->
554
+ <!-- Structural metadata: assigned module. Dropped while the lanes are GROUPED by module,
555
+ where the group header above the card already names it — two chips saying the same thing
556
+ cost a row of card height each and add nothing. -->
543
557
  <div
544
- v-if="task.moduleName"
558
+ v-if="showModuleChip"
545
559
  class="mt-2 flex flex-wrap items-center gap-1 border-t border-slate-800 pt-2"
546
560
  >
547
561
  <span
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ import LaneGroup from './LaneGroup.vue'
3
+ import type { RenderedLane } from '~/composables/useFrameLanes'
4
+ import { LANE_GEOMETRY } from '~/utils/laneGeometry'
5
+ import type { LaneGroupKey } from '~/utils/laneSort'
6
+ import { LANE_META } from '~/utils/swimlanes'
7
+
8
+ /**
9
+ * One status lane inside a service frame.
10
+ *
11
+ * The lane BODY is the frame's drop zone, so a card dropped into any of another frame's lanes
12
+ * moves to that service. Which lane it lands in is not a choice a drop can make: the lane is
13
+ * derived from the task's state, so dropping a not-started card on "In progress" would have to
14
+ * either lie or silently ignore the gesture. Reparenting is the one thing a drag decides.
15
+ *
16
+ * The body SCROLLS rather than growing. A lane is a viewport onto an unbounded list, which is
17
+ * what keeps a busy service's frame the same size as a quiet one's; its height comes from the
18
+ * frame, so dragging the frame's border gives the reader more of the lane.
19
+ *
20
+ * It takes the whole {@link RenderedLane} rather than spreading it across three props, so the
21
+ * lane object stays one thing. Restating its fields here also collided the `LaneGroup` NAME with
22
+ * the component of that name imported above, leaving one identifier meaning the interface in
23
+ * type position and the component in value position.
24
+ */
25
+ const props = defineProps<{
26
+ rendered: RenderedLane
27
+ groupKey: LaneGroupKey
28
+ frameId: string
29
+ /** The scroll viewport's height, resolved by the frame from its own size. */
30
+ bodyHeight: number
31
+ }>()
32
+
33
+ const { t } = useI18n()
34
+ const meta = computed(() => LANE_META[props.rendered.lane])
35
+ const isEmpty = computed(() => props.rendered.groups.every((g) => g.entries.length === 0))
36
+ </script>
37
+
38
+ <template>
39
+ <div
40
+ class="flex min-w-0 flex-col rounded-lg bg-slate-900/40"
41
+ :style="{ width: LANE_GEOMETRY.laneWidth + 'px' }"
42
+ :data-lane="rendered.lane"
43
+ >
44
+ <!-- Lane header -->
45
+ <div
46
+ class="flex items-center gap-1.5 rounded-t-lg border-b px-2 py-1.5"
47
+ :style="{ borderColor: meta.color + '33' }"
48
+ >
49
+ <UIcon :name="meta.icon" class="h-3.5 w-3.5 shrink-0" :style="{ color: meta.color }" />
50
+ <span class="truncate text-[11px] font-semibold text-slate-200">{{ t(meta.labelKey) }}</span>
51
+ <span
52
+ class="ms-auto shrink-0 rounded px-1 text-[10px] font-semibold tabular-nums"
53
+ :style="{ backgroundColor: meta.color + '22', color: meta.color }"
54
+ :data-testid="`lane-count-${rendered.lane}`"
55
+ >{{ rendered.total }}</span
56
+ >
57
+ </div>
58
+
59
+ <!-- Lane body: the frame's drop zone, and the scroll viewport. -->
60
+ <div
61
+ :data-drop-zone="frameId"
62
+ :data-testid="`lane-${rendered.lane}`"
63
+ class="nodrag flex-1 space-y-2 overflow-y-auto overflow-x-hidden p-2"
64
+ :style="{ height: bodyHeight + 'px' }"
65
+ >
66
+ <!-- An empty lane SAYS it is empty. Left blank, "nothing needs you" and "the lane failed
67
+ to render" look identical, and the first is worth stating: it is the answer a reader
68
+ scanning the needs-you column is hoping for. -->
69
+ <p v-if="isEmpty" class="px-1 pt-2 text-[10px] leading-snug text-slate-600">
70
+ {{ t(meta.emptyKey) }}
71
+ </p>
72
+ <LaneGroup
73
+ v-for="(group, i) in rendered.groups"
74
+ v-else
75
+ :key="group.label ?? `catch-all-${i}`"
76
+ :group="group"
77
+ :group-key="groupKey"
78
+ :frame-id="frameId"
79
+ />
80
+ </div>
81
+ </div>
82
+ </template>
@@ -2,6 +2,7 @@
2
2
  import { useBoardFlow, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
3
3
  import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
4
4
  import IconButton from '~/components/common/IconButton.vue'
5
+ import LaneViewControl from '~/components/board/LaneViewControl.vue'
5
6
 
6
7
  const ui = useUiStore()
7
8
  const board = useBoardStore()
@@ -209,6 +210,9 @@ const decisionItems = computed(() =>
209
210
  </UButton>
210
211
  </UDropdownMenu>
211
212
 
213
+ <!-- how every frame's swimlanes are ordered + grouped (an override; advanced tier) -->
214
+ <LaneViewControl />
215
+
212
216
  <!-- in-org sharing: add an existing org service to this board (mount = board.write) -->
213
217
  <UDropdownMenu
214
218
  v-if="mountableItems.length && access.canWriteBoard.value"
@@ -30,6 +30,7 @@ const PURPOSE_LABELS = computed<Record<PipelinePurpose, string>>(() => ({
30
30
  review: t('pipeline.builder.purposeOption.review'),
31
31
  research: t('pipeline.builder.purposeOption.research'),
32
32
  planning: t('pipeline.builder.purposeOption.planning'),
33
+ media: t('pipeline.builder.purposeOption.media'),
33
34
  }))
34
35
 
35
36
  // The button text. A stored purpose this build has no label for is NAMED as unrecognised and
@@ -319,10 +319,23 @@ const showOriginalDescription = ref(false)
319
319
  </script>
320
320
 
321
321
  <template>
322
+ <!-- On lg+ the panel is a rail in the board pane's end corner, and it CLEARS the board's top
323
+ overlay region rather than sitting beside it (`top-16`, below the region's toolbar pill).
324
+ That region has one owner, `BoardTopOverlays`, and this rail is deliberately not a member:
325
+ it is an end-anchored side panel, not centred chrome. Clearing the region is how a
326
+ non-member stays out of the owner's way.
327
+ Overlapping is not a cosmetic problem. The toolbar is centred and grows with its contents,
328
+ so at some width its end reaches this corner, and whichever of the two is on top covers the
329
+ other's controls and EATS THEIR CLICKS: the click lands on the box above and no handler
330
+ runs, which reads as a dead button rather than as two overlapping boxes. Adding the
331
+ swimlane view control was the width that finally did it, to the notifications bell (then
332
+ with the panel on top; the region now paints above at `z-40`, which only swaps which side
333
+ loses). Sitting the panel below fixes it whichever way the stacking goes, and needs no
334
+ left/right arithmetic to stay correct under RTL. -->
322
335
  <div
323
336
  v-if="block && statusMeta && typeMeta"
324
337
  data-testid="inspector-panel"
325
- class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-4 lg:w-80 lg:rounded-2xl"
338
+ class="fixed inset-x-0 bottom-0 z-20 overflow-hidden rounded-t-2xl border border-slate-700 bg-slate-900/95 shadow-2xl backdrop-blur lg:absolute lg:inset-x-auto lg:bottom-auto lg:end-4 lg:top-16 lg:w-80 lg:rounded-2xl"
326
339
  >
327
340
  <div class="h-1.5 w-full" :style="{ backgroundColor: statusMeta.color }" />
328
341
  <!-- A tall task (execution steps + scenarios + docs) can overflow the
@@ -331,7 +344,7 @@ const showOriginalDescription = ref(false)
331
344
  On compact viewports the panel is a bottom sheet capped to the visible
332
345
  height (dvh excludes mobile browser chrome). -->
333
346
  <div
334
- class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-5rem)]"
347
+ class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-7rem)]"
335
348
  >
336
349
  <!-- header -->
337
350
  <div class="flex items-start justify-between gap-2">
@@ -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') }}
@@ -148,7 +148,7 @@ const tabsUi = {
148
148
  // so the config surface pins the built-in set it renders inputs for — a custom type buckets on its
149
149
  // own id server-side (`RunAdmission`) and is not configured here. Keying the records below off this
150
150
  // finite union (not the open `CreateTaskType`) keeps them exhaustive + undefined-free.
151
- type LimitTaskType = 'feature' | 'bug' | 'document' | 'spike' | 'review' | 'ralph'
151
+ type LimitTaskType = 'feature' | 'bug' | 'document' | 'spike' | 'review' | 'ralph' | 'media'
152
152
  const TASK_TYPES: LimitTaskType[] = ['feature', 'bug', 'document', 'spike']
153
153
 
154
154
  // Per-task-type label for the "Max {type} tasks" inputs. An exhaustive Record keyed off
@@ -161,6 +161,7 @@ const TASK_TYPE_KEYS: Record<LimitTaskType, string> = {
161
161
  spike: 'settings.workspaceSettings.taskTypes.spike',
162
162
  review: 'settings.workspaceSettings.taskTypes.review',
163
163
  ralph: 'settings.workspaceSettings.taskTypes.ralph',
164
+ media: 'settings.workspaceSettings.taskTypes.media',
164
165
  }
165
166
 
166
167
  const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
@@ -197,6 +198,9 @@ const draft = reactive({
197
198
  storeAgentContext: true,
198
199
  publishPrVerificationReport: true,
199
200
  artifactRetentionDays: 14,
201
+ doneLaneMaxItems: 20,
202
+ doneLaneRetentionEnabled: true,
203
+ doneLaneRetentionDays: 14 as number,
200
204
  kaizenEnabled: true,
201
205
  allowInitiatorPat: true,
202
206
  inputGateMode: 'standard' as InputGateMode,
@@ -218,6 +222,12 @@ function hydrate() {
218
222
  draft.storeAgentContext = s.storeAgentContext
219
223
  draft.publishPrVerificationReport = s.publishPrVerificationReport
220
224
  draft.artifactRetentionDays = s.artifactRetentionDays
225
+ draft.doneLaneMaxItems = s.doneLaneMaxItems
226
+ // Nullable (null ⇒ no age cap), so a checkbox is derived from whether a value is stored
227
+ // and the number input keeps a sensible starting value to switch back on with — the same
228
+ // shape the nullable review-friction triggers below use.
229
+ draft.doneLaneRetentionEnabled = s.doneLaneRetentionDays != null
230
+ draft.doneLaneRetentionDays = s.doneLaneRetentionDays ?? 14
221
231
  draft.kaizenEnabled = s.kaizenEnabled
222
232
  draft.allowInitiatorPat = s.allowInitiatorPat
223
233
  draft.inputGateMode = s.inputGateMode
@@ -276,6 +286,8 @@ async function save() {
276
286
  storeAgentContext: draft.storeAgentContext,
277
287
  publishPrVerificationReport: draft.publishPrVerificationReport,
278
288
  artifactRetentionDays: draft.artifactRetentionDays,
289
+ doneLaneMaxItems: draft.doneLaneMaxItems,
290
+ doneLaneRetentionDays: draft.doneLaneRetentionEnabled ? draft.doneLaneRetentionDays : null,
279
291
  kaizenEnabled: draft.kaizenEnabled,
280
292
  allowInitiatorPat: draft.allowInitiatorPat,
281
293
  inputGateMode: draft.inputGateMode,
@@ -540,6 +552,54 @@ async function save() {
540
552
  </label>
541
553
  </section>
542
554
 
555
+ <!-- What the board's Done swimlane keeps in view -->
556
+ <section class="space-y-2">
557
+ <h3 class="text-sm font-semibold text-slate-200">
558
+ {{ t('settings.workspaceSettings.doneLane.heading') }}
559
+ </h3>
560
+ <p class="text-[11px] text-slate-400">
561
+ {{ t('settings.workspaceSettings.doneLane.body') }}
562
+ </p>
563
+ <label class="block w-48">
564
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
565
+ {{ t('settings.workspaceSettings.doneLane.maxItems') }}
566
+ </span>
567
+ <UInput
568
+ v-model.number="draft.doneLaneMaxItems"
569
+ type="number"
570
+ :min="0"
571
+ :max="500"
572
+ size="sm"
573
+ data-testid="done-lane-max-items"
574
+ />
575
+ </label>
576
+ <p v-if="draft.doneLaneMaxItems === 0" class="text-[11px] text-slate-500">
577
+ {{ t('settings.workspaceSettings.doneLane.zeroHint') }}
578
+ </p>
579
+ <label class="flex items-center gap-2">
580
+ <UCheckbox v-model="draft.doneLaneRetentionEnabled" size="sm" />
581
+ <span class="text-[11px] text-slate-300">{{
582
+ t('settings.workspaceSettings.doneLane.ageToggle')
583
+ }}</span>
584
+ </label>
585
+ <label v-if="draft.doneLaneRetentionEnabled" class="block w-48">
586
+ <span class="mb-1 block text-[10px] uppercase tracking-wide text-slate-500">
587
+ {{ t('settings.workspaceSettings.doneLane.days') }}
588
+ </span>
589
+ <UInput
590
+ v-model.number="draft.doneLaneRetentionDays"
591
+ type="number"
592
+ :min="1"
593
+ :max="3650"
594
+ size="sm"
595
+ data-testid="done-lane-retention-days"
596
+ />
597
+ </label>
598
+ <p class="text-[11px] text-slate-500">
599
+ {{ t('settings.workspaceSettings.doneLane.hidesOnlyHint') }}
600
+ </p>
601
+ </section>
602
+
543
603
  <!-- Run credential: the App installation vs. the initiator's own token -->
544
604
  <section class="space-y-2">
545
605
  <h3 class="text-sm font-semibold text-slate-200">
@@ -49,12 +49,21 @@ export function visualConfirmApi({ send, ws, http }: ApiContext) {
49
49
  },
50
50
 
51
51
  // Fetch a stored artifact's bytes and turn them into an object URL for an <img>.
52
- fetchArtifactBlobUrl: async (workspaceId: string, artifactId: string): Promise<string> => {
52
+ //
53
+ // The blob's own `type` comes back beside the URL because it is the media type the SERVER
54
+ // decided to serve these bytes as, which is the only one a caller may render from. What the
55
+ // producing agent declared is a claim about a file; this is what the response actually is,
56
+ // after `blobResponseHeaders` has clamped anything outside the inline-image list down to
57
+ // `application/octet-stream`.
58
+ fetchArtifactBlob: async (
59
+ workspaceId: string,
60
+ artifactId: string,
61
+ ): Promise<{ url: string; contentType: string }> => {
53
62
  const blob: Blob = await http(
54
63
  `${ws(workspaceId)}/artifacts/${encodeURIComponent(artifactId)}/blob`,
55
64
  { method: 'GET', responseType: 'blob' },
56
65
  )
57
- return URL.createObjectURL(blob)
66
+ return { url: URL.createObjectURL(blob), contentType: blob.type }
58
67
  },
59
68
  }
60
69
  }