@cat-factory/app 0.193.0 → 0.195.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/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/pipeline/AgentPromptEditor.vue +40 -0
- package/app/components/pipeline/OutputBudgetInput.vue +69 -0
- package/app/components/pipeline/PipelineBuilder.vue +42 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +34 -0
- package/app/composables/api/agentSettings.ts +32 -0
- package/app/composables/api/board.ts +7 -1
- package/app/composables/api/context.ts +2 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useFrameResize.ts +116 -22
- package/app/stores/agentSettings.ts +86 -0
- package/app/stores/board/placement.ts +64 -1
- package/app/stores/board.spec.ts +51 -0
- package/app/stores/outputBudget.spec.ts +55 -0
- package/app/stores/pipelines/draftStepConfig.ts +23 -0
- package/app/types/agent-settings.ts +12 -0
- package/app/types/requirements.ts +1 -0
- package/i18n/locales/de.json +16 -2
- package/i18n/locales/en.json +20 -2
- package/i18n/locales/es.json +16 -2
- package/i18n/locales/fr.json +16 -2
- package/i18n/locales/he.json +16 -2
- package/i18n/locales/it.json +16 -2
- package/i18n/locales/ja.json +16 -2
- package/i18n/locales/pl.json +16 -2
- package/i18n/locales/tr.json +16 -2
- package/i18n/locales/uk.json +16 -2
- package/package.json +2 -2
|
@@ -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>
|
|
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
|
|
|
3
3
|
import type { AgentPromptRevision } from '~/types/agent-prompts'
|
|
4
4
|
import { agentKindMeta } from '~/utils/catalog'
|
|
5
5
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
6
|
+
import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
6
7
|
import {
|
|
7
8
|
draftForRevision,
|
|
8
9
|
isDirty,
|
|
@@ -30,6 +31,7 @@ const emit = defineEmits<{ close: [] }>()
|
|
|
30
31
|
const { t } = useI18n()
|
|
31
32
|
const toast = useToast()
|
|
32
33
|
const prompts = useAgentPromptsStore()
|
|
34
|
+
const agentSettings = useAgentSettingsStore()
|
|
33
35
|
|
|
34
36
|
const open = computed({
|
|
35
37
|
get: () => props.agentKind !== null,
|
|
@@ -76,6 +78,29 @@ watch(
|
|
|
76
78
|
const detail = computed(() => prompts.detail)
|
|
77
79
|
const label = computed(() => (props.agentKind ? agentKindMeta(props.agentKind).label : ''))
|
|
78
80
|
|
|
81
|
+
/**
|
|
82
|
+
* The workspace-wide output-token ceiling for this kind — the same per-agent-kind scope this
|
|
83
|
+
* editor already owns for the prompt, which is why it lives here rather than in a settings screen
|
|
84
|
+
* of its own. A pipeline step may still pin its own budget over it.
|
|
85
|
+
*
|
|
86
|
+
* Saved on its own, immediately: unlike the prompt (whose save appends a revision and wants an
|
|
87
|
+
* explicit commit) this is one scalar with no history, so a separate Save button would only invite
|
|
88
|
+
* someone to type a number, close the modal and wonder why nothing changed.
|
|
89
|
+
*/
|
|
90
|
+
const budget = computed(() =>
|
|
91
|
+
props.agentKind ? agentSettings.maxOutputTokensFor(props.agentKind) : undefined,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
async function saveBudget(value: number | null) {
|
|
95
|
+
const kind = props.agentKind
|
|
96
|
+
if (!kind) return
|
|
97
|
+
try {
|
|
98
|
+
await agentSettings.setMaxOutputTokens(kind, value)
|
|
99
|
+
} catch {
|
|
100
|
+
toast.add({ title: t('agentPrompt.toast.budgetFailed'), color: 'error' })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
79
104
|
/** The live prompt, so "no change" can be reported instead of appending an identical revision. */
|
|
80
105
|
const dirty = computed(() => isDirty(draft.value, detail.value))
|
|
81
106
|
/** Nothing to revert to when the workspace is already running the shipped prompt. */
|
|
@@ -164,6 +189,21 @@ function revisionLabel(revision: AgentPromptRevision): string {
|
|
|
164
189
|
</UBadge>
|
|
165
190
|
</div>
|
|
166
191
|
|
|
192
|
+
<!-- The workspace-wide output ceiling for this kind. Same per-agent-kind scope as the
|
|
193
|
+
prompt below it; saves on change, since there is no revision log to commit to. -->
|
|
194
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
195
|
+
<span class="text-[11px] text-slate-400">{{ t('pipeline.outputBudget.kindLabel') }}</span>
|
|
196
|
+
<OutputBudgetInput
|
|
197
|
+
class="w-32"
|
|
198
|
+
:model-value="budget"
|
|
199
|
+
:disabled="agentSettings.saving"
|
|
200
|
+
@update:model-value="saveBudget"
|
|
201
|
+
/>
|
|
202
|
+
<span class="text-[10px] text-slate-500">
|
|
203
|
+
{{ t('pipeline.outputBudget.kindHint') }}
|
|
204
|
+
</span>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
167
207
|
<!-- What the platform appends is SHOWN, not described. A prose summary of it is copy
|
|
168
208
|
that silently goes stale the moment a directive is added, and a user who does not
|
|
169
209
|
know what is already there writes a prompt that fights it. -->
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The output-token ceiling control, shared by both tiers that configure one: a pipeline step's
|
|
4
|
+
* own override (in the builder) and a workspace's per-agent-kind default (in the prompt editor).
|
|
5
|
+
*
|
|
6
|
+
* Empty means INHERIT, and that is the whole reason this is a component rather than a bare
|
|
7
|
+
* `UInput type="number"` at each call site: "no value" has to round-trip as `null` and never as
|
|
8
|
+
* `0`, or clearing the field would send a zero-token ceiling — every reply empty — instead of
|
|
9
|
+
* falling back to the next tier. The bounds come from the contract, so the input cannot offer a
|
|
10
|
+
* value the server would reject.
|
|
11
|
+
*/
|
|
12
|
+
import { computed } from 'vue'
|
|
13
|
+
import { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '~/types/agent-settings'
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
/** The configured ceiling, or null/undefined when this tier inherits. */
|
|
17
|
+
modelValue: number | null | undefined
|
|
18
|
+
/** What this tier falls back to, shown as the placeholder so "inherit" names a number. */
|
|
19
|
+
inheritedValue?: number | undefined
|
|
20
|
+
disabled?: boolean
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{ 'update:modelValue': [number | null] }>()
|
|
24
|
+
|
|
25
|
+
const { t, n } = useI18n()
|
|
26
|
+
|
|
27
|
+
/** Bound to the input as a string so an empty field is distinguishable from a typed 0. */
|
|
28
|
+
const text = computed(() => (props.modelValue != null ? String(props.modelValue) : ''))
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The placeholder names the inherited ceiling when the caller knows it, so a user reading an
|
|
32
|
+
* empty field learns what the step will actually run on rather than just that it is unset.
|
|
33
|
+
*/
|
|
34
|
+
const placeholder = computed(() =>
|
|
35
|
+
props.inheritedValue != null
|
|
36
|
+
? t('pipeline.outputBudget.inheritsValue', { tokens: n(props.inheritedValue) })
|
|
37
|
+
: t('pipeline.outputBudget.inherits'),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Commit on change. An empty/unparseable field clears to `null` (inherit); anything else is
|
|
42
|
+
* clamped into the contract's range rather than rejected, so a fat-fingered extra digit lands on
|
|
43
|
+
* the ceiling instead of silently failing the save with a 422.
|
|
44
|
+
*/
|
|
45
|
+
function commit(raw: string | number) {
|
|
46
|
+
const trimmed = String(raw).trim()
|
|
47
|
+
if (trimmed === '') return emit('update:modelValue', null)
|
|
48
|
+
const parsed = Number.parseInt(trimmed, 10)
|
|
49
|
+
if (!Number.isFinite(parsed)) return emit('update:modelValue', null)
|
|
50
|
+
const clamped = Math.min(
|
|
51
|
+
MAX_AGENT_MAX_OUTPUT_TOKENS,
|
|
52
|
+
Math.max(MIN_AGENT_MAX_OUTPUT_TOKENS, parsed),
|
|
53
|
+
)
|
|
54
|
+
emit('update:modelValue', clamped)
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
<template>
|
|
59
|
+
<UInput
|
|
60
|
+
:model-value="text"
|
|
61
|
+
type="number"
|
|
62
|
+
size="xs"
|
|
63
|
+
:min="MIN_AGENT_MAX_OUTPUT_TOKENS"
|
|
64
|
+
:max="MAX_AGENT_MAX_OUTPUT_TOKENS"
|
|
65
|
+
:placeholder="placeholder"
|
|
66
|
+
:disabled="disabled"
|
|
67
|
+
@change="commit(($event.target as HTMLInputElement).value)"
|
|
68
|
+
/>
|
|
69
|
+
</template>
|
|
@@ -5,6 +5,7 @@ import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
|
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
6
6
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
7
7
|
import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
|
|
8
|
+
import OutputBudgetInput from '~/components/pipeline/OutputBudgetInput.vue'
|
|
8
9
|
import { showOverrideField } from '~/utils/uiMode'
|
|
9
10
|
import {
|
|
10
11
|
agentKindMeta,
|
|
@@ -86,6 +87,7 @@ const agents = useAgentsStore()
|
|
|
86
87
|
const ui = useUiStore()
|
|
87
88
|
const uiMode = useUiModeStore()
|
|
88
89
|
const agentPrompts = useAgentPromptsStore()
|
|
90
|
+
const agentSettings = useAgentSettingsStore()
|
|
89
91
|
|
|
90
92
|
// The agent kind whose system prompt is open in the editor (null = closed). Per-KIND, not
|
|
91
93
|
// per-step: an override applies to every run of that agent in the workspace, so two steps of
|
|
@@ -102,6 +104,26 @@ const promptEditorKind = ref<AgentKind | null>(null)
|
|
|
102
104
|
function showPromptEditor(kind: AgentKind): boolean {
|
|
103
105
|
return showOverrideField(uiMode.isAdvanced, agentPrompts.isCustomized(kind) || null)
|
|
104
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether this step's own output-budget field shows. Same override rule as the prompt editor: a
|
|
110
|
+
* step with no pinned ceiling inherits, so the control is advanced-only until a value actually
|
|
111
|
+
* exists — at which point it must be visible in BOTH tiers, or a basic-mode user runs on a budget
|
|
112
|
+
* a teammate pinned and they can neither see nor clear.
|
|
113
|
+
*/
|
|
114
|
+
function showOutputBudget(index: number): boolean {
|
|
115
|
+
return showOverrideField(uiMode.isAdvanced, pipelines.draftMaxOutputTokens(index) ?? null)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What a step with no pinned ceiling actually runs on: the workspace's per-kind setting when it
|
|
120
|
+
* has one. Shown as the field's placeholder. Undefined ⇒ the deployment default, which the SPA
|
|
121
|
+
* deliberately does NOT guess at — it is env-resolved per agent kind and a number invented here
|
|
122
|
+
* would be wrong on the deployments that tuned it.
|
|
123
|
+
*/
|
|
124
|
+
function inheritedOutputBudget(kind: AgentKind): number | undefined {
|
|
125
|
+
return agentSettings.maxOutputTokensFor(kind)
|
|
126
|
+
}
|
|
105
127
|
const releaseHealth = useReleaseHealthStore()
|
|
106
128
|
const skills = useSkillsStore()
|
|
107
129
|
|
|
@@ -151,6 +173,9 @@ watch(open, (isOpen) => {
|
|
|
151
173
|
// Best-effort: the builder is fully usable without it, and a deployment that wires no
|
|
152
174
|
// override store answers 503 here.
|
|
153
175
|
if (isOpen) agentPrompts.loadIndex().catch(() => {})
|
|
176
|
+
// The workspace's per-kind output ceilings, which the per-step field shows as its inherited
|
|
177
|
+
// placeholder and the prompt editor edits. Best-effort on the same terms as the prompt index.
|
|
178
|
+
if (isOpen) agentSettings.load().catch(() => {})
|
|
154
179
|
})
|
|
155
180
|
|
|
156
181
|
function add(kind: AgentKind) {
|
|
@@ -694,6 +719,23 @@ async function clone(p: Pipeline) {
|
|
|
694
719
|
</p>
|
|
695
720
|
</div>
|
|
696
721
|
|
|
722
|
+
<!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
|
|
723
|
+
setting (itself an override of the deployment routing default), so it is
|
|
724
|
+
advanced-only until a value is pinned; empty inherits. -->
|
|
725
|
+
<div v-if="showOutputBudget(unit.index)" class="ms-6 flex items-center gap-2">
|
|
726
|
+
<span class="text-[10px] text-slate-500">
|
|
727
|
+
{{ t('pipeline.outputBudget.stepLabel') }}
|
|
728
|
+
</span>
|
|
729
|
+
<OutputBudgetInput
|
|
730
|
+
class="w-28"
|
|
731
|
+
:model-value="pipelines.draftMaxOutputTokens(unit.index)"
|
|
732
|
+
:inherited-value="inheritedOutputBudget(unit.kind)"
|
|
733
|
+
@update:model-value="
|
|
734
|
+
pipelines.setDraftMaxOutputTokens(unit.index, $event ?? undefined)
|
|
735
|
+
"
|
|
736
|
+
/>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
697
739
|
<!-- Attached companion: a dependent reviewer for this producer, optionally
|
|
698
740
|
gated on the task estimate. -->
|
|
699
741
|
<div
|
|
@@ -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>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
listWorkspaceAgentSettingsContract,
|
|
3
|
+
updateWorkspaceAgentSettingsContract,
|
|
4
|
+
} from '@cat-factory/contracts'
|
|
5
|
+
import type { UpdateWorkspaceAgentSettingsInput } from '~/types/agent-settings'
|
|
6
|
+
import type { ApiContext } from './context'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The workspace's per-agent-kind generation settings, edited from the pipeline builder. There is
|
|
10
|
+
* no delete route: clearing the last configured field IS the way back to the deployment default,
|
|
11
|
+
* and the server drops the row when nothing is left set.
|
|
12
|
+
*/
|
|
13
|
+
export function agentSettingsApi({ send, ws }: ApiContext) {
|
|
14
|
+
return {
|
|
15
|
+
// Every kind the workspace has configured — nothing for a kind that inherits.
|
|
16
|
+
listWorkspaceAgentSettings: (workspaceId: string) =>
|
|
17
|
+
send(listWorkspaceAgentSettingsContract, { pathPrefix: ws(workspaceId) }),
|
|
18
|
+
|
|
19
|
+
// Patch one kind. An explicit null clears the field; the response is null once the kind is
|
|
20
|
+
// back to inheriting entirely.
|
|
21
|
+
updateWorkspaceAgentSettings: (
|
|
22
|
+
workspaceId: string,
|
|
23
|
+
agentKind: string,
|
|
24
|
+
body: UpdateWorkspaceAgentSettingsInput,
|
|
25
|
+
) =>
|
|
26
|
+
send(updateWorkspaceAgentSettingsContract, {
|
|
27
|
+
pathPrefix: ws(workspaceId),
|
|
28
|
+
pathParams: { agentKind },
|
|
29
|
+
body,
|
|
30
|
+
}),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -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,
|