@cat-factory/app 0.192.0 → 0.194.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/app/components/board/AddTaskModal.vue +7 -5
- package/app/components/board/RecurringPipelineModal.vue +11 -6
- package/app/components/board/nodes/BlockNode.vue +28 -46
- package/app/components/board/nodes/ModuleFrame.vue +4 -33
- package/app/components/board/nodes/ResizeGrips.vue +130 -0
- package/app/components/focus/BlockFocusView.vue +11 -3
- package/app/components/panels/InspectorPanel.vue +15 -4
- package/app/components/panels/inspector/TaskRunSettings.vue +5 -1
- package/app/components/requirements/RequirementsReviewWindow.vue +34 -0
- package/app/composables/api/board.ts +7 -1
- package/app/composables/api/context.ts +2 -0
- package/app/composables/useFrameResize.ts +116 -22
- package/app/composables/usePipelineHealth.spec.ts +32 -0
- package/app/composables/usePipelineHealth.ts +23 -4
- package/app/stores/board/placement.ts +64 -1
- package/app/stores/board.spec.ts +51 -0
- package/app/types/requirements.ts +1 -0
- package/app/utils/pipeline.spec.ts +85 -5
- package/app/utils/pipeline.ts +32 -11
- package/i18n/locales/de.json +7 -1
- package/i18n/locales/en.json +11 -1
- package/i18n/locales/es.json +7 -1
- package/i18n/locales/fr.json +7 -1
- package/i18n/locales/he.json +7 -1
- package/i18n/locales/it.json +7 -1
- package/i18n/locales/ja.json +7 -1
- package/i18n/locales/pl.json +7 -1
- package/i18n/locales/tr.json +7 -1
- package/i18n/locales/uk.json +7 -1
- package/package.json +2 -2
|
@@ -387,13 +387,15 @@ const fragmentPool = computed(() => fragments.forBlockType(frame.value?.type ??
|
|
|
387
387
|
|
|
388
388
|
// Hide UI-testing pipelines (`tester-ui` / `visual-confirmation`) when the target frame has no
|
|
389
389
|
// UI to exercise — they'd be refused server-side (see utils/pipeline + the backend gate). Also
|
|
390
|
-
// hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and
|
|
391
|
-
//
|
|
392
|
-
// a
|
|
393
|
-
//
|
|
390
|
+
// hide `'recurring'`-only pipelines (a one-off task start of one is refused at run start) and every
|
|
391
|
+
// pipeline whose purpose doesn't match the chosen task type (a doc task authors a doc, a review task
|
|
392
|
+
// reviews a PR, and a `feature`/`bug` task ships code — so it is offered build + research only, not
|
|
393
|
+
// the doc/review/planning presets). `blockLevel: 'task'` is passed literally because this modal only
|
|
394
|
+
// ever creates a task leaf, which also drops the three planning presets the backend would refuse.
|
|
395
|
+
// Re-filters as the chosen task type changes.
|
|
394
396
|
const selectablePipelines = computed(() =>
|
|
395
397
|
pipelines.pipelines.filter((p) =>
|
|
396
|
-
pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value),
|
|
398
|
+
pipelineAllowedForManualStart(p, frame.value, board.blocks, taskType.value, 'task'),
|
|
397
399
|
),
|
|
398
400
|
)
|
|
399
401
|
// Some task types want their type-default pipeline surfaced in the modal up front, so picking the
|
|
@@ -74,9 +74,15 @@ const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
|
|
|
74
74
|
|
|
75
75
|
// Infer the template from the picked pipeline so the backend seeds the right block
|
|
76
76
|
// description (and so we know to show the tracker config).
|
|
77
|
+
//
|
|
78
|
+
// Only the pipelines whose SHAPE is specific to one kind of recurring work can be inferred this
|
|
79
|
+
// way. `dep-update` no longer can: its pipeline was retired in the catalog collapse (it was the
|
|
80
|
+
// ordinary build tail under a recurring name), so a dependency-update schedule now runs an ordinary
|
|
81
|
+
// build rung — which is also what every generic schedule runs, so inferring the template from it
|
|
82
|
+
// would mislabel all of them. The template itself survives for an explicit API caller; see
|
|
83
|
+
// `scheduleTemplateSchema`.
|
|
77
84
|
const template = computed<ScheduleTemplate>(() => {
|
|
78
85
|
if (pipelineId.value === 'pl_tech_debt') return 'tech-debt'
|
|
79
|
-
if (pipelineId.value === 'pl_dep_update') return 'dep-update'
|
|
80
86
|
if (pipelineId.value === 'pl_bug_triage') return 'bug-triage'
|
|
81
87
|
return 'custom'
|
|
82
88
|
})
|
|
@@ -99,11 +105,10 @@ watch(open, (isOpen) => {
|
|
|
99
105
|
if (!isOpen) return
|
|
100
106
|
name.value = ''
|
|
101
107
|
description.value = ''
|
|
102
|
-
// Default to the
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
''
|
|
108
|
+
// Default to the first schedulable pipeline, which is the ladder's own default rung. There is no
|
|
109
|
+
// longer a canned recurring build preset to prefer — the dependency-update pipeline was the
|
|
110
|
+
// ordinary build tail under another name — so the default build is the honest starting point.
|
|
111
|
+
pipelineId.value = selectablePipelines.value[0]?.id ?? pipelines.pipelines[0]?.id ?? ''
|
|
107
112
|
recurrence.value = defaultRecurrence()
|
|
108
113
|
onDemand.value = false
|
|
109
114
|
saving.value = false
|
|
@@ -5,10 +5,10 @@ import DecisionBadge from './DecisionBadge.vue'
|
|
|
5
5
|
import DraggableTask from './DraggableTask.vue'
|
|
6
6
|
import InitiativeCard from './InitiativeCard.vue'
|
|
7
7
|
import ModuleFrame from './ModuleFrame.vue'
|
|
8
|
+
import ResizeGrips from './ResizeGrips.vue'
|
|
8
9
|
import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
|
|
9
10
|
import AgentStopButton from '~/components/board/AgentStopButton.vue'
|
|
10
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
11
|
-
import { useFrameResize } from '~/composables/useFrameResize'
|
|
12
12
|
import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
13
13
|
import { useViewport } from '~/composables/useViewport'
|
|
14
14
|
|
|
@@ -148,14 +148,6 @@ function onFrameHandle(e: PointerEvent) {
|
|
|
148
148
|
// (see useFrameStacking + BoardCanvas's frameZIndex).
|
|
149
149
|
const { enter: enterFrame, leave: leaveFrame } = useFrameStacking()
|
|
150
150
|
|
|
151
|
-
// Miro-style frame resizing: drag the right / bottom edges or the corner. Handles
|
|
152
|
-
// live on the expanded card's drop zone (see template); the composable clamps to
|
|
153
|
-
// the frame's content extent and persists the size on release.
|
|
154
|
-
const { startResize } = useFrameResize()
|
|
155
|
-
function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
156
|
-
if (block.value) startResize(block.value, e, edge)
|
|
157
|
-
}
|
|
158
|
-
|
|
159
151
|
function addTask() {
|
|
160
152
|
ui.expandFrame(props.id)
|
|
161
153
|
ui.openAddTask(props.id)
|
|
@@ -385,7 +377,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
385
377
|
<!-- ===================== EXPANDED: 2D canvas of tasks + modules ===================== -->
|
|
386
378
|
<div
|
|
387
379
|
v-else
|
|
388
|
-
class="overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
|
|
380
|
+
class="relative overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
|
|
389
381
|
:class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
|
|
390
382
|
>
|
|
391
383
|
<div class="h-1.5 w-full rounded-t-2xl" :style="{ backgroundColor: accent }" />
|
|
@@ -563,16 +555,23 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
563
555
|
</div>
|
|
564
556
|
</div>
|
|
565
557
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
558
|
+
<!-- Composition line. It deliberately does NOT carry a done/total tally: the
|
|
559
|
+
per-task status is already on every card in the canvas below, so the
|
|
560
|
+
frame-level "N/M implemented" was a second, coarser answer to a question
|
|
561
|
+
the canvas answers precisely — and the one people misread, since it counts
|
|
562
|
+
every task ever added to the service rather than the work in flight. What
|
|
563
|
+
stays is what the canvas can't show at a glance. -->
|
|
564
|
+
<div
|
|
565
|
+
v-if="modules.length || prTasks"
|
|
566
|
+
class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500"
|
|
567
|
+
>
|
|
568
|
+
<span v-if="modules.length">{{
|
|
569
|
+
t('board.frame.moduleCount', { count: modules.length }, modules.length)
|
|
570
|
+
}}</span>
|
|
571
|
+
<span v-if="modules.length && prTasks" aria-hidden="true">·</span>
|
|
572
|
+
<span v-if="prTasks" class="text-emerald-400">{{
|
|
573
|
+
t('board.frame.prReadyCount', { count: prTasks })
|
|
569
574
|
}}</span>
|
|
570
|
-
<span v-if="modules.length"
|
|
571
|
-
>· {{ t('board.frame.moduleCount', { count: modules.length }, modules.length) }}</span
|
|
572
|
-
>
|
|
573
|
-
<span v-if="prTasks" class="text-emerald-400"
|
|
574
|
-
>· {{ t('board.frame.prReadyCount', { count: prTasks }) }}</span
|
|
575
|
-
>
|
|
576
575
|
</div>
|
|
577
576
|
</div>
|
|
578
577
|
|
|
@@ -594,35 +593,18 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
594
593
|
>
|
|
595
594
|
<UIcon name="i-lucide-plus" class="h-3.5 w-3.5" /> {{ t('board.frame.addFirstTask') }}
|
|
596
595
|
</button>
|
|
597
|
-
|
|
598
|
-
<!-- resize handles (drag the borders to resize the service, Miro-style).
|
|
599
|
-
`nopan` (alongside `nodrag`) so the pane doesn't pan while resizing —
|
|
600
|
-
same reason as the header handle above. These stay PHYSICAL
|
|
601
|
-
(`right-0`, not `end-0`): the resize math in useFrameResize grows the
|
|
602
|
-
right/bottom edge from an unmirrored clientX/clientY delta, so a
|
|
603
|
-
logical (RTL-flipped) grip would render on the opposite edge from the
|
|
604
|
-
one the drag actually moves. -->
|
|
605
|
-
<div
|
|
606
|
-
class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize touch-none hover:bg-sky-400/20 pointer-coarse:w-4"
|
|
607
|
-
:title="t('board.frame.dragToResize')"
|
|
608
|
-
@pointerdown="onResize($event, 'e')"
|
|
609
|
-
/>
|
|
610
|
-
<div
|
|
611
|
-
class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize touch-none hover:bg-sky-400/20 pointer-coarse:h-4"
|
|
612
|
-
:title="t('board.frame.dragToResize')"
|
|
613
|
-
@pointerdown="onResize($event, 's')"
|
|
614
|
-
/>
|
|
615
|
-
<div
|
|
616
|
-
class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none pointer-coarse:h-11 pointer-coarse:w-11"
|
|
617
|
-
:title="t('board.frame.dragToResize')"
|
|
618
|
-
@pointerdown="onResize($event, 'se')"
|
|
619
|
-
>
|
|
620
|
-
<span
|
|
621
|
-
class="absolute bottom-1 right-1 h-2 w-2 rounded-sm border-b-2 border-r-2 border-slate-500"
|
|
622
|
-
/>
|
|
623
|
-
</div>
|
|
624
596
|
</div>
|
|
625
597
|
</div>
|
|
598
|
+
|
|
599
|
+
<!-- Every border and corner of the CARD is a resize grip (see ResizeGrips: the geometry,
|
|
600
|
+
the hit bands, and why a north/west drag translates the contents). They used to sit on
|
|
601
|
+
the inner drop zone's edge, 16px of padding inside the visible border and flush against
|
|
602
|
+
the content, where two thin strips read as scrollbars rather than as the frame's border.
|
|
603
|
+
|
|
604
|
+
The grips are PHYSICAL (`right-0`, not `end-0`): the resize math derives the box from an
|
|
605
|
+
unmirrored clientX/clientY delta, so a logical (RTL-flipped) grip would render on the
|
|
606
|
+
opposite border from the one the drag actually moves. -->
|
|
607
|
+
<ResizeGrips :block="block" tone="frame" />
|
|
626
608
|
</div>
|
|
627
609
|
</div>
|
|
628
610
|
</template>
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import DraggableTask from './DraggableTask.vue'
|
|
3
|
+
import ResizeGrips from './ResizeGrips.vue'
|
|
3
4
|
import { MODULE_META } from '~/utils/catalog'
|
|
4
5
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
5
|
-
import { useFrameResize } from '~/composables/useFrameResize'
|
|
6
6
|
|
|
7
7
|
const props = defineProps<{ moduleId: string }>()
|
|
8
8
|
const board = useBoardStore()
|
|
@@ -25,14 +25,6 @@ const { draggingId, startDrag } = useBlockDrag()
|
|
|
25
25
|
function onHandle(e: PointerEvent) {
|
|
26
26
|
if (mod.value) startDrag(mod.value, e)
|
|
27
27
|
}
|
|
28
|
-
|
|
29
|
-
// Miro-style resizing, same as a service frame: drag the right / bottom edges or
|
|
30
|
-
// the corner. The composable clamps to the module's content extent and persists
|
|
31
|
-
// the size on release.
|
|
32
|
-
const { startResize } = useFrameResize()
|
|
33
|
-
function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
34
|
-
if (mod.value) startResize(mod.value, e, edge)
|
|
35
|
-
}
|
|
36
28
|
</script>
|
|
37
29
|
|
|
38
30
|
<template>
|
|
@@ -74,29 +66,8 @@ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
|
|
|
74
66
|
<DraggableTask v-for="t in tasks" :key="t.id" :task-id="t.id" />
|
|
75
67
|
</div>
|
|
76
68
|
|
|
77
|
-
<!--
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
BlockNode: the resize delta is unmirrored, so a logical grip would sit on
|
|
81
|
-
the opposite edge from the one the drag moves. -->
|
|
82
|
-
<div
|
|
83
|
-
class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize touch-none hover:bg-violet-400/20 pointer-coarse:w-4"
|
|
84
|
-
:title="t('board.frame.dragToResize')"
|
|
85
|
-
@pointerdown="onResize($event, 'e')"
|
|
86
|
-
/>
|
|
87
|
-
<div
|
|
88
|
-
class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize touch-none hover:bg-violet-400/20 pointer-coarse:h-4"
|
|
89
|
-
:title="t('board.frame.dragToResize')"
|
|
90
|
-
@pointerdown="onResize($event, 's')"
|
|
91
|
-
/>
|
|
92
|
-
<div
|
|
93
|
-
class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none pointer-coarse:h-11 pointer-coarse:w-11"
|
|
94
|
-
:title="t('board.frame.dragToResize')"
|
|
95
|
-
@pointerdown="onResize($event, 'se')"
|
|
96
|
-
>
|
|
97
|
-
<span
|
|
98
|
-
class="absolute bottom-1 right-1 h-2 w-2 rounded-sm border-b-2 border-r-2 border-violet-400/60"
|
|
99
|
-
/>
|
|
100
|
-
</div>
|
|
69
|
+
<!-- Every border and corner of the module box is a resize grip, the same component (and so
|
|
70
|
+
the same hit bands + content-preserving north/west behaviour) the service frame uses. -->
|
|
71
|
+
<ResizeGrips :block="mod" tone="module" />
|
|
101
72
|
</div>
|
|
102
73
|
</template>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { Block } from '~/types/domain'
|
|
3
|
+
import { RESIZE_EDGES, type ResizeEdge, useFrameResize } from '~/composables/useFrameResize'
|
|
4
|
+
|
|
5
|
+
// The eight border/corner grips of a resizable container (a service frame or a module), shared so
|
|
6
|
+
// the two node components can't drift in hit area, geometry, or which borders exist.
|
|
7
|
+
//
|
|
8
|
+
// Each grip STRADDLES the border it moves — half outside the box, half inside — giving a 12px hit
|
|
9
|
+
// band on a fine pointer (24px on a coarse one) while the DRAWN affordance stays a 2px bar on the
|
|
10
|
+
// border itself. The grips used to live on the frame's inner drop zone, 16px inside the visible
|
|
11
|
+
// border and flush against the content, where two thin strips read as scrollbars rather than as
|
|
12
|
+
// the box's edge.
|
|
13
|
+
//
|
|
14
|
+
// Corners are rendered LAST so they win the overlap with both of their edges. `RESIZE_EDGES` is
|
|
15
|
+
// ordered accordingly (edges first), and the whole set comes from the composable, so adding a
|
|
16
|
+
// direction is one entry there rather than markup here.
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
/** The container being resized. Its position/size are what the drag writes. */
|
|
19
|
+
block: Block
|
|
20
|
+
/** Which palette to draw in — a frame's grips are sky, a module's violet. */
|
|
21
|
+
tone: 'frame' | 'module'
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const access = useWorkspaceAccess()
|
|
25
|
+
const { resizingId, startResize } = useFrameResize()
|
|
26
|
+
const resizing = computed(() => resizingId.value === props.block.id)
|
|
27
|
+
|
|
28
|
+
// Which grip the pointer rests on, and which one it grabbed. The lit bar is a CHILD of the grip's
|
|
29
|
+
// hit band, so a plain `hover:` utility on the bar is wrong (the pointer is over the band, not the
|
|
30
|
+
// 2px bar); tracking it in state instead of a `group-hover/<name>:` variant means ONE predicate
|
|
31
|
+
// lights the border whether the pointer is resting on it or dragging it. The drag reads `dragEdge`
|
|
32
|
+
// rather than `hoverEdge` on purpose: the pointer routinely leaves the band mid-drag, and the
|
|
33
|
+
// border it is moving must stay lit until the drag ends.
|
|
34
|
+
const hoverEdge = ref<ResizeEdge | null>(null)
|
|
35
|
+
const dragEdge = ref<ResizeEdge | null>(null)
|
|
36
|
+
const lit = (edge: ResizeEdge) =>
|
|
37
|
+
resizing.value ? dragEdge.value === edge : hoverEdge.value === edge
|
|
38
|
+
|
|
39
|
+
function onPointerDown(e: PointerEvent, edge: ResizeEdge) {
|
|
40
|
+
dragEdge.value = edge
|
|
41
|
+
startResize(props.block, e, edge)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Tailwind needs literal class names, so the geometry is a static table rather than interpolated
|
|
45
|
+
// strings. `box` positions the hit band (straddling, widened for a coarse pointer); `mark` draws
|
|
46
|
+
// the lit affordance — a bar along an edge, an L at a corner, each anchored ON the border by
|
|
47
|
+
// matching the box's own negative offset.
|
|
48
|
+
interface GripStyle {
|
|
49
|
+
box: string
|
|
50
|
+
mark: string
|
|
51
|
+
/** Corners keep a faint mark at rest, so the gesture is discoverable without a hover. */
|
|
52
|
+
idle?: boolean
|
|
53
|
+
}
|
|
54
|
+
const GRIPS: Record<ResizeEdge, GripStyle> = {
|
|
55
|
+
n: {
|
|
56
|
+
box: '-top-1.5 left-0 h-3 w-full cursor-ns-resize pointer-coarse:-top-3 pointer-coarse:h-6',
|
|
57
|
+
mark: 'inset-x-3 top-1/2 h-0.5 -translate-y-1/2 rounded-full',
|
|
58
|
+
},
|
|
59
|
+
s: {
|
|
60
|
+
box: '-bottom-1.5 left-0 h-3 w-full cursor-ns-resize pointer-coarse:-bottom-3 pointer-coarse:h-6',
|
|
61
|
+
mark: 'inset-x-3 top-1/2 h-0.5 -translate-y-1/2 rounded-full',
|
|
62
|
+
},
|
|
63
|
+
e: {
|
|
64
|
+
box: '-right-1.5 top-0 h-full w-3 cursor-ew-resize pointer-coarse:-right-3 pointer-coarse:w-6',
|
|
65
|
+
mark: 'inset-y-3 left-1/2 w-0.5 -translate-x-1/2 rounded-full',
|
|
66
|
+
},
|
|
67
|
+
w: {
|
|
68
|
+
box: '-left-1.5 top-0 h-full w-3 cursor-ew-resize pointer-coarse:-left-3 pointer-coarse:w-6',
|
|
69
|
+
mark: 'inset-y-3 left-1/2 w-0.5 -translate-x-1/2 rounded-full',
|
|
70
|
+
},
|
|
71
|
+
ne: {
|
|
72
|
+
box: '-top-1.5 -right-1.5 h-5 w-5 cursor-nesw-resize pointer-coarse:-top-3 pointer-coarse:-right-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
73
|
+
mark: 'right-1.5 top-1.5 h-2 w-2 rounded-sm border-r-2 border-t-2 pointer-coarse:right-3 pointer-coarse:top-3',
|
|
74
|
+
},
|
|
75
|
+
nw: {
|
|
76
|
+
box: '-left-1.5 -top-1.5 h-5 w-5 cursor-nwse-resize pointer-coarse:-left-3 pointer-coarse:-top-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
77
|
+
mark: 'left-1.5 top-1.5 h-2 w-2 rounded-sm border-l-2 border-t-2 pointer-coarse:left-3 pointer-coarse:top-3',
|
|
78
|
+
},
|
|
79
|
+
se: {
|
|
80
|
+
box: '-bottom-1.5 -right-1.5 h-5 w-5 cursor-nwse-resize pointer-coarse:-bottom-3 pointer-coarse:-right-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
81
|
+
mark: 'bottom-1.5 right-1.5 h-2 w-2 rounded-sm border-b-2 border-r-2 pointer-coarse:bottom-3 pointer-coarse:right-3',
|
|
82
|
+
idle: true,
|
|
83
|
+
},
|
|
84
|
+
sw: {
|
|
85
|
+
box: '-bottom-1.5 -left-1.5 h-5 w-5 cursor-nesw-resize pointer-coarse:-bottom-3 pointer-coarse:-left-3 pointer-coarse:h-11 pointer-coarse:w-11',
|
|
86
|
+
mark: 'bottom-1.5 left-1.5 h-2 w-2 rounded-sm border-b-2 border-l-2 pointer-coarse:bottom-3 pointer-coarse:left-3',
|
|
87
|
+
},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A bar is filled, an L-shaped corner mark is drawn in border colour — so the lit/idle classes
|
|
91
|
+
// differ per shape as well as per tone.
|
|
92
|
+
const TONES = {
|
|
93
|
+
frame: { barLit: 'bg-sky-400', markLit: 'border-sky-400', markIdle: 'border-slate-500' },
|
|
94
|
+
module: {
|
|
95
|
+
barLit: 'bg-violet-300',
|
|
96
|
+
markLit: 'border-violet-300',
|
|
97
|
+
markIdle: 'border-violet-400/60',
|
|
98
|
+
},
|
|
99
|
+
} as const
|
|
100
|
+
|
|
101
|
+
function markClass(edge: ResizeEdge): string {
|
|
102
|
+
const tone = TONES[props.tone]
|
|
103
|
+
const isCorner = edge.length === 2
|
|
104
|
+
if (!isCorner) return lit(edge) ? tone.barLit : 'bg-transparent'
|
|
105
|
+
if (lit(edge)) return tone.markLit
|
|
106
|
+
return GRIPS[edge].idle ? tone.markIdle : 'border-transparent'
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<!-- Resizing persists geometry, so it is a `board.write` mutation: a read-only viewer gets no
|
|
112
|
+
grips at all rather than borders that light up and no-op (which is what `startResize`'s own
|
|
113
|
+
permission check then backs up rather than carries alone). `nopan` alongside `nodrag` so the
|
|
114
|
+
pane doesn't pan while resizing, same reason as the node's own drag handle. -->
|
|
115
|
+
<template v-if="access.canWriteBoard.value">
|
|
116
|
+
<div
|
|
117
|
+
v-for="edge in RESIZE_EDGES"
|
|
118
|
+
:key="edge"
|
|
119
|
+
class="nodrag nopan absolute z-10 touch-none"
|
|
120
|
+
:class="GRIPS[edge].box"
|
|
121
|
+
:title="$t('board.frame.dragToResize')"
|
|
122
|
+
:data-testid="`resize-${edge}`"
|
|
123
|
+
@pointerenter="hoverEdge = edge"
|
|
124
|
+
@pointerleave="hoverEdge = null"
|
|
125
|
+
@pointerdown="onPointerDown($event, edge)"
|
|
126
|
+
>
|
|
127
|
+
<span class="absolute transition-colors" :class="[GRIPS[edge].mark, markClass(edge)]" />
|
|
128
|
+
</div>
|
|
129
|
+
</template>
|
|
130
|
+
</template>
|
|
@@ -30,12 +30,20 @@ const deps = computed(() =>
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
// Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
|
|
33
|
-
// pipelines (a manual run of one is refused server-side), and
|
|
34
|
-
//
|
|
33
|
+
// pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
|
|
34
|
+
// match this block's task type or LEVEL (per the `purpose` classifier) — see the backend gate. The
|
|
35
|
+
// level is what keeps the planning presets on initiative blocks and off everything else, in both
|
|
36
|
+
// directions, so this menu never offers a run the engine answers with a 409.
|
|
35
37
|
const runOptions = computed(() => {
|
|
36
38
|
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
37
39
|
return pipelines.pipelines.filter((p) =>
|
|
38
|
-
pipelineAllowedForManualStart(
|
|
40
|
+
pipelineAllowedForManualStart(
|
|
41
|
+
p,
|
|
42
|
+
frame,
|
|
43
|
+
board.blocks,
|
|
44
|
+
block.value?.taskType,
|
|
45
|
+
block.value?.level,
|
|
46
|
+
),
|
|
39
47
|
)
|
|
40
48
|
})
|
|
41
49
|
|
|
@@ -178,13 +178,24 @@ const taskBranchUrl = computed(() => {
|
|
|
178
178
|
return base ? `${base}/tree/${pr.branch}` : null
|
|
179
179
|
})
|
|
180
180
|
|
|
181
|
-
// Hide UI-testing pipelines when this block's frame has no UI to exercise,
|
|
182
|
-
// pipelines (a manual run of one is refused server-side)
|
|
183
|
-
// utils/pipeline + the
|
|
181
|
+
// Hide UI-testing pipelines when this block's frame has no UI to exercise, `'recurring'`-only
|
|
182
|
+
// pipelines (a manual run of one is refused server-side), and every pipeline whose purpose doesn't
|
|
183
|
+
// match this block's task type or LEVEL — they'd be refused at run start (see utils/pipeline + the
|
|
184
|
+
// backend gate). The level is what keeps the planning presets on initiative blocks and off
|
|
185
|
+
// everything else, so this menu never offers a run the engine answers with a 409; it applies to
|
|
186
|
+
// frames and modules too, which is why it reads `block.level` rather than assuming a task.
|
|
184
187
|
const runMenu = computed(() => {
|
|
185
188
|
const frame = block.value ? board.serviceOf(block.value) : undefined
|
|
186
189
|
return pipelines.pipelines
|
|
187
|
-
.filter((p) =>
|
|
190
|
+
.filter((p) =>
|
|
191
|
+
pipelineAllowedForManualStart(
|
|
192
|
+
p,
|
|
193
|
+
frame,
|
|
194
|
+
board.blocks,
|
|
195
|
+
block.value?.taskType,
|
|
196
|
+
block.value?.level,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
188
199
|
.map((p) => ({
|
|
189
200
|
label: p.name,
|
|
190
201
|
icon: 'i-lucide-play',
|
|
@@ -144,10 +144,14 @@ const selectedPipeline = computed(() =>
|
|
|
144
144
|
// pipelines (the task's manual Run control can't start one), and — for a `document` task — every
|
|
145
145
|
// non-document pipeline (it authors a doc, so a build/test pipeline makes no sense). All would be
|
|
146
146
|
// refused / wrong at run start (see utils/pipeline + the backend gate + the purpose classifier).
|
|
147
|
+
// `blockLevel: 'task'` is passed literally because this panel only ever edits a task leaf, which
|
|
148
|
+
// drops the planning presets the engine would refuse. The task-type narrowing already excludes them
|
|
149
|
+
// for `feature`/`bug`, but NOT for a `spike` / `ralph` / deployment-custom type — and a planning
|
|
150
|
+
// preset settable as a task's DEFAULT pipeline is a 409 on every later Start.
|
|
147
151
|
const taskFrame = computed(() => board.serviceOf(props.block))
|
|
148
152
|
const selectablePipelines = computed(() =>
|
|
149
153
|
pipelines.pipelines.filter((p) =>
|
|
150
|
-
pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType),
|
|
154
|
+
pipelineAllowedForManualStart(p, taskFrame.value, board.blocks, props.block.taskType, 'task'),
|
|
151
155
|
),
|
|
152
156
|
)
|
|
153
157
|
function setPipeline(id: string) {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type OrderedFinding,
|
|
18
18
|
} from './RequirementsReviewWindow.logic'
|
|
19
19
|
import type {
|
|
20
|
+
RecommendationSource,
|
|
20
21
|
RequirementRecommendation,
|
|
21
22
|
RequirementReview,
|
|
22
23
|
RequirementReviewItem,
|
|
@@ -150,6 +151,17 @@ const STATUS_COLOR = {
|
|
|
150
151
|
recommend_requested: 'primary',
|
|
151
152
|
} as const satisfies Record<ReviewItemStatus, string>
|
|
152
153
|
|
|
154
|
+
// What a Writer suggestion actually rests on. Shown because a suggestion drawn from the team's own
|
|
155
|
+
// standards and one resting on nothing but the model look identical once they are sitting in the
|
|
156
|
+
// answer box, and they deserve very different scrutiny. A suggestion whose standard resolved already
|
|
157
|
+
// carries the richer "current standard" badge, so this fills in the rest rather than repeating it.
|
|
158
|
+
const GROUNDING_COLOR = {
|
|
159
|
+
standard: 'success',
|
|
160
|
+
'project-spec': 'info',
|
|
161
|
+
web: 'neutral',
|
|
162
|
+
'general-practice': 'warning',
|
|
163
|
+
} as const satisfies Record<RecommendationSource, string>
|
|
164
|
+
|
|
153
165
|
// Exhaustive enum→label maps of literal keys, so the typed-key drift guard sees each key
|
|
154
166
|
// (vs a runtime-built `requirements.severity.${value}`).
|
|
155
167
|
const SEVERITY_LABELS = computed<Record<ReviewItemSeverity, string>>(() => ({
|
|
@@ -157,6 +169,12 @@ const SEVERITY_LABELS = computed<Record<ReviewItemSeverity, string>>(() => ({
|
|
|
157
169
|
medium: t('requirements.severity.medium'),
|
|
158
170
|
low: t('requirements.severity.low'),
|
|
159
171
|
}))
|
|
172
|
+
const GROUNDING_LABELS = computed<Record<RecommendationSource, string>>(() => ({
|
|
173
|
+
standard: t('requirements.grounding.standard'),
|
|
174
|
+
'project-spec': t('requirements.grounding.project-spec'),
|
|
175
|
+
web: t('requirements.grounding.web'),
|
|
176
|
+
'general-practice': t('requirements.grounding.general-practice'),
|
|
177
|
+
}))
|
|
160
178
|
const CATEGORY_LABELS = computed<Record<ReviewItemCategory, string>>(() => ({
|
|
161
179
|
gap: t('requirements.category.gap'),
|
|
162
180
|
clarification: t('requirements.category.clarification'),
|
|
@@ -842,6 +860,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
842
860
|
})
|
|
843
861
|
}}
|
|
844
862
|
</UBadge>
|
|
863
|
+
<UBadge
|
|
864
|
+
v-else-if="autoDefaults.get(item.id)!.groundedIn"
|
|
865
|
+
size="xs"
|
|
866
|
+
variant="subtle"
|
|
867
|
+
:color="GROUNDING_COLOR[autoDefaults.get(item.id)!.groundedIn!]"
|
|
868
|
+
>
|
|
869
|
+
{{ GROUNDING_LABELS[autoDefaults.get(item.id)!.groundedIn!] }}
|
|
870
|
+
</UBadge>
|
|
845
871
|
</div>
|
|
846
872
|
<UTextarea
|
|
847
873
|
v-model="drafts[item.id]"
|
|
@@ -891,6 +917,14 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
891
917
|
})
|
|
892
918
|
}}
|
|
893
919
|
</UBadge>
|
|
920
|
+
<UBadge
|
|
921
|
+
v-else-if="rec.groundedIn"
|
|
922
|
+
size="xs"
|
|
923
|
+
variant="subtle"
|
|
924
|
+
:color="GROUNDING_COLOR[rec.groundedIn]"
|
|
925
|
+
>
|
|
926
|
+
{{ GROUNDING_LABELS[rec.groundedIn] }}
|
|
927
|
+
</UBadge>
|
|
894
928
|
<p class="mt-1 whitespace-pre-line text-sm text-slate-300">
|
|
895
929
|
{{ rec.recommendedText }}
|
|
896
930
|
</p>
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
removeBlockContract,
|
|
16
16
|
reparentBlockContract,
|
|
17
17
|
reseedPipelineContract,
|
|
18
|
+
resizeBlockContract,
|
|
18
19
|
restoreBlockContract,
|
|
19
20
|
toggleDependencyContract,
|
|
20
21
|
updateBlockContract,
|
|
@@ -26,7 +27,7 @@ import type {
|
|
|
26
27
|
UpdatePipelineInput,
|
|
27
28
|
} from '@cat-factory/contracts'
|
|
28
29
|
import type { BlockType, CreateTaskType, TaskTypeFields } from '~/types/domain'
|
|
29
|
-
import type { ApiContext, Position } from './context'
|
|
30
|
+
import type { ApiContext, Position, Size } from './context'
|
|
30
31
|
|
|
31
32
|
/** Board structure: block (frame/module/task) mutations + the pipeline library. */
|
|
32
33
|
export function boardApi({ send, ws }: ApiContext) {
|
|
@@ -80,6 +81,11 @@ export function boardApi({ send, ws }: ApiContext) {
|
|
|
80
81
|
moveBlock: (workspaceId: string, blockId: string, body: { position: Position }) =>
|
|
81
82
|
send(moveBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
|
|
82
83
|
|
|
84
|
+
// Border-drag resize. One call for both halves of the geometry: the server derives the
|
|
85
|
+
// origin delta and translates the container's children so its contents stay put.
|
|
86
|
+
resizeBlock: (workspaceId: string, blockId: string, body: { position: Position; size: Size }) =>
|
|
87
|
+
send(resizeBlockContract, { pathPrefix: ws(workspaceId), pathParams: { blockId }, body }),
|
|
88
|
+
|
|
83
89
|
reparentBlock: (
|
|
84
90
|
workspaceId: string,
|
|
85
91
|
blockId: string,
|