@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.
@@ -3,6 +3,7 @@ import { createApiClient, createSend, createSendWith } from './api/client'
3
3
  import type { ApiContext } from './api/context'
4
4
  import { accountsApi } from './api/accounts'
5
5
  import { agentPromptsApi } from './api/agentPrompts'
6
+ import { agentSettingsApi } from './api/agentSettings'
6
7
  import { platformObservabilityApi } from './api/platformObservability'
7
8
  import { reportsApi } from './api/reports'
8
9
  import { authApi } from './api/auth'
@@ -136,6 +137,7 @@ export function useApi() {
136
137
  ...notificationsApi(ctx),
137
138
  ...presetsApi(ctx),
138
139
  ...agentPromptsApi(ctx),
140
+ ...agentSettingsApi(ctx),
139
141
  ...preflightsApi(ctx),
140
142
  ...publicApiKeysApi(ctx),
141
143
  ...sharedStacksApi(ctx),
@@ -2,56 +2,150 @@ import { ref } from 'vue'
2
2
  import type { Block } from '~/types/domain'
3
3
 
4
4
  /**
5
- * Pointer-driven resizing for service frames (Miro-style border drag). The drag
6
- * delta is divided by the board zoom so the edge tracks the cursor, and the new
7
- * size is clamped to the frame's content extent so dragging in never clips the
8
- * tasks/modules inside. The frame grows live (the store block is mutated in place,
9
- * which `containerSize` reads back), and the final size is persisted once on
10
- * release rather than on every move.
5
+ * The eight border/corner grips, each as the pair of unit factors saying how far the drag moves
6
+ * the container's ORIGIN versus its far edge. `1` on an origin axis means that axis's border is
7
+ * the one being dragged (west/north), so the box grows the OPPOSITE way from the pointer delta.
8
+ *
9
+ * Encoding the geometry as data rather than a `switch` per axis is what keeps the corners honest:
10
+ * `nw` is exactly `n` and `w` applied together, and there is no eighth case to forget.
11
+ */
12
+ const HANDLES = {
13
+ n: { ox: 0, oy: 1, sx: 0, sy: -1, cursor: 'ns-resize' },
14
+ s: { ox: 0, oy: 0, sx: 0, sy: 1, cursor: 'ns-resize' },
15
+ e: { ox: 0, oy: 0, sx: 1, sy: 0, cursor: 'ew-resize' },
16
+ w: { ox: 1, oy: 0, sx: -1, sy: 0, cursor: 'ew-resize' },
17
+ ne: { ox: 0, oy: 1, sx: 1, sy: -1, cursor: 'nesw-resize' },
18
+ nw: { ox: 1, oy: 1, sx: -1, sy: -1, cursor: 'nwse-resize' },
19
+ se: { ox: 0, oy: 0, sx: 1, sy: 1, cursor: 'nwse-resize' },
20
+ sw: { ox: 1, oy: 0, sx: -1, sy: 1, cursor: 'nesw-resize' },
21
+ } as const
22
+
23
+ export type ResizeEdge = keyof typeof HANDLES
24
+
25
+ /** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
26
+ export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
27
+
28
+ /** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
29
+ export function resizeCursor(edge: ResizeEdge): string {
30
+ return HANDLES[edge].cursor
31
+ }
32
+
33
+ /**
34
+ * Pointer-driven resizing for containers (service frames and modules) by dragging any border or
35
+ * corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
36
+ * cursor, and the new size is clamped to the container's content extent so dragging inwards never
37
+ * clips the tasks/modules inside.
38
+ *
39
+ * Dragging the north or west border also moves the container's ORIGIN, and a child's position is
40
+ * stored relative to that origin — so the store translates the children by the inverse (see
41
+ * `previewResize`) and the backend does the same on commit, which is what makes the border extend
42
+ * past the contents instead of dragging them along. The origin is derived from the CLAMPED size
43
+ * rather than from the raw pointer delta: once the box has hit its content floor the border must
44
+ * stop dead, and a separately-clamped origin would keep sliding, walking the whole container
45
+ * across the board.
46
+ *
47
+ * The container grows live off the store's optimistic geometry, and the final bounds are
48
+ * persisted ONCE on release rather than on every move.
11
49
  */
12
50
  export function useFrameResize() {
13
51
  const board = useBoardStore()
14
52
  const ui = useUiStore()
15
53
  const access = useWorkspaceAccess()
16
- /** Id of the frame currently being resized, for cursor/handle styling. */
54
+ /** Id of the container currently being resized, for cursor/grip styling. */
17
55
  const resizingId = ref<string | null>(null)
18
56
 
19
57
  /**
20
- * Begin a resize from one of the frame's edges/corner. `edge` selects which
21
- * dimensions move: `'e'` width only, `'s'` height only, `'se'` both.
58
+ * How far the origin may travel INWARD before the nearest child would land at a negative
59
+ * offset i.e. outside the box, spilling over the very border being dragged. `contentSize` is
60
+ * no help here: it measures only the FAR edge of the contents, which a north/west shrink moves
61
+ * inward in step with the border, so nothing there ever objects. `Infinity` for an empty
62
+ * container, which is then bounded by `contentSize`'s empty floor alone.
22
63
  */
23
- function startResize(block: Block, e: PointerEvent, edge: 'e' | 's' | 'se') {
64
+ function originSlack(id: string): { x: number; y: number } {
65
+ const children = board.childrenOf(id)
66
+ if (!children.length) return { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }
67
+ return {
68
+ x: Math.min(...children.map((c) => c.position.x)),
69
+ y: Math.min(...children.map((c) => c.position.y)),
70
+ }
71
+ }
72
+
73
+ /** Begin a resize from one of the container's borders or corners. */
74
+ function startResize(block: Block, e: PointerEvent, edge: ResizeEdge) {
24
75
  if (e.button !== 0) return
25
- // Resizing a frame persists its size — a `board.write` mutation, so a read-only
26
- // viewer's resize no-ops (the grips are hidden for them at the component level).
76
+ // Resizing persists geometry — a `board.write` mutation, so a read-only viewer's resize
77
+ // no-ops (the grips are hidden for them at the component level).
27
78
  if (!access.canWriteBoard.value) return
28
79
  e.preventDefault()
29
80
  e.stopPropagation()
81
+ const handle = HANDLES[edge]
30
82
  const startX = e.clientX
31
83
  const startY = e.clientY
32
- // The content extent is the floor never shrink a frame below its tasks.
33
- const min = board.contentSize(block.id)
34
- // Seed from the current rendered size so the first move doesn't jump.
84
+ // Seed from the current rendered geometry so the first move doesn't jump. `size` may be
85
+ // absent (an auto-sized container), which is also what a rejected resize must restore.
35
86
  const start = board.containerSize(block.id)
87
+ const from = {
88
+ position: { ...block.position },
89
+ size: block.size ? { ...block.size } : undefined,
90
+ }
91
+ // The floors, snapshotted once: on an origin-axis drag the children MOVE, so a floor re-read
92
+ // mid-drag would chase them. `contentSize` bounds the far edge; the near-edge bound applies
93
+ // only where the origin travels, and only inwards.
94
+ const min = board.contentSize(block.id)
95
+ const slack = originSlack(block.id)
96
+ const floor = {
97
+ w: handle.ox ? Math.max(min.w, start.w - slack.x) : min.w,
98
+ h: handle.oy ? Math.max(min.h, start.h - slack.y) : min.h,
99
+ }
36
100
  resizingId.value = block.id
37
101
 
102
+ // Hold the resize cursor on `<body>` (and kill text selection) for the whole drag: the
103
+ // pointer routinely outruns the 12px grip, and without this the cursor flips back to the
104
+ // default mid-drag, which reads as "the grab was dropped" even though the border is still
105
+ // tracking.
106
+ const body = document.body
107
+ const priorCursor = body.style.cursor
108
+ const priorUserSelect = body.style.userSelect
109
+ body.style.cursor = handle.cursor
110
+ body.style.userSelect = 'none'
111
+
112
+ let bounds = { position: from.position, size: start }
113
+ let moved = false
38
114
  const onMove = (ev: PointerEvent) => {
39
115
  const z = ui.zoom || 1
40
- const w = edge === 's' ? start.w : Math.max(min.w, start.w + (ev.clientX - startX) / z)
41
- const h = edge === 'e' ? start.h : Math.max(min.h, start.h + (ev.clientY - startY) / z)
42
- // Optimistic, local-only: mutate the cached block so the frame grows live
43
- // without a round-trip on every pointer move.
44
- block.size = { w: Math.round(w), h: Math.round(h) }
116
+ const dx = (ev.clientX - startX) / z
117
+ const dy = (ev.clientY - startY) / z
118
+ const w = Math.round(Math.max(floor.w, start.w + handle.sx * dx))
119
+ const h = Math.round(Math.max(floor.h, start.h + handle.sy * dy))
120
+ // A grown box on an origin axis extends BACKWARDS from where the far edge stays put, so
121
+ // the origin moves by whatever the size actually gained after clamping.
122
+ bounds = {
123
+ position: {
124
+ x: from.position.x - handle.ox * (w - start.w),
125
+ y: from.position.y - handle.oy * (h - start.h),
126
+ },
127
+ size: { w, h },
128
+ }
129
+ moved = true
130
+ // Optimistic, local-only (the store also translates the children): no round-trip per move.
131
+ board.previewResize(block.id, bounds.position, bounds.size)
45
132
  }
46
133
  const onUp = () => {
47
134
  window.removeEventListener('pointermove', onMove)
48
135
  window.removeEventListener('pointerup', onUp)
136
+ window.removeEventListener('pointercancel', onUp)
137
+ body.style.cursor = priorCursor
138
+ body.style.userSelect = priorUserSelect
49
139
  resizingId.value = null
50
- // Persist the final size once (also re-applies it as the authoritative block).
51
- if (block.size) void board.updateBlock(block.id, { size: block.size })
140
+ // A press with no movement is not a resize: committing it would emit a coarse board signal
141
+ // (every other client re-hydrates) to store the geometry it already had.
142
+ if (moved) void board.resizeBlock(block.id, bounds, from)
52
143
  }
53
144
  window.addEventListener('pointermove', onMove)
54
145
  window.addEventListener('pointerup', onUp)
146
+ // A cancelled pointer (touch interrupted by a gesture, window losing the pointer) never fires
147
+ // `pointerup`, so without this the body cursor stays stuck on `ew-resize` for the session.
148
+ window.addEventListener('pointercancel', onUp)
55
149
  }
56
150
 
57
151
  return { resizingId, startResize }
@@ -0,0 +1,86 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+ import type { WorkspaceAgentSettings } from '~/types/agent-settings'
4
+ import { useWorkspaceStore } from '~/stores/workspace'
5
+
6
+ /**
7
+ * The workspace's per-agent-kind generation settings — the pipeline builder's output-budget
8
+ * control, sitting beside the prompt editor.
9
+ *
10
+ * Unlike the prompt store there is only ONE shape to load: the whole configured set is a handful
11
+ * of small rows (a kind that inherits has no row at all), so the index IS the detail and the
12
+ * builder can badge every step and populate its editor from one request. There is nothing here
13
+ * worth deferring the way a prompt body is.
14
+ */
15
+ export const useAgentSettingsStore = defineStore('agentSettings', () => {
16
+ const api = useApi()
17
+
18
+ const settings = ref<WorkspaceAgentSettings[]>([])
19
+ const loading = ref(false)
20
+ const saving = ref(false)
21
+
22
+ /** Configured ceilings by agent kind, for O(1) lookup while rendering a pipeline's steps. */
23
+ const ceilingByKind = computed(() => {
24
+ const out = new Map<string, number>()
25
+ for (const row of settings.value) {
26
+ if (row.maxOutputTokens != null) out.set(row.agentKind, row.maxOutputTokens)
27
+ }
28
+ return out
29
+ })
30
+
31
+ /** This kind's configured ceiling, or undefined when it inherits the deployment default. */
32
+ function maxOutputTokensFor(agentKind: string): number | undefined {
33
+ return ceilingByKind.value.get(agentKind)
34
+ }
35
+
36
+ /**
37
+ * Load the configured set. Best-effort like the prompt index: the builder is fully usable
38
+ * without it (every kind simply runs the deployment ceiling), and the endpoint 503s on a
39
+ * deployment that wires no settings store at all.
40
+ */
41
+ async function load() {
42
+ const ws = useWorkspaceStore()
43
+ if (!ws.workspaceId) return
44
+ loading.value = true
45
+ try {
46
+ settings.value = await api.listWorkspaceAgentSettings(ws.requireId())
47
+ } finally {
48
+ loading.value = false
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Set (or clear, with `null`) one kind's output-token ceiling.
54
+ *
55
+ * Reconciles from the row the SERVER returned rather than the value sent: it answers `null`
56
+ * once the kind is back to inheriting, which is the same signal the row should disappear —
57
+ * so a clear and a set both land through one code path and the store can never keep a row the
58
+ * server has dropped.
59
+ */
60
+ async function setMaxOutputTokens(agentKind: string, maxOutputTokens: number | null) {
61
+ const ws = useWorkspaceStore()
62
+ saving.value = true
63
+ try {
64
+ const updated = await api.updateWorkspaceAgentSettings(ws.requireId(), agentKind, {
65
+ maxOutputTokens,
66
+ })
67
+ const rest = settings.value.filter((s) => s.agentKind !== agentKind)
68
+ settings.value = updated
69
+ ? [...rest, updated].sort((a, b) => a.agentKind.localeCompare(b.agentKind))
70
+ : rest
71
+ return updated
72
+ } finally {
73
+ saving.value = false
74
+ }
75
+ }
76
+
77
+ return {
78
+ settings,
79
+ loading,
80
+ saving,
81
+ ceilingByKind,
82
+ maxOutputTokensFor,
83
+ load,
84
+ setMaxOutputTokens,
85
+ }
86
+ })
@@ -12,7 +12,7 @@ import { UNDO_WINDOW_MS } from './context'
12
12
  * in-closure functions, and the split is purely to keep every function within the size budget.
13
13
  */
14
14
  export function createBoardPlacement(ctx: BoardWriteContext) {
15
- const { getBlock, upsert, api, toast, tr } = ctx
15
+ const { blocks, getBlock, upsert, api, toast, tr } = ctx
16
16
 
17
17
  /**
18
18
  * Move a block into a new container at a new local position. Drag-reparent commits
@@ -120,6 +120,67 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * Translate every DIRECT child of a container — the client half of the compensation the
125
+ * backend's `shiftChildPositions` applies. A child's position is relative to its container's
126
+ * content origin, so moving that origin (a north/west border drag) has to move the children
127
+ * the other way or the contents slide with the border. Grandchildren ride their module.
128
+ */
129
+ function shiftChildren(parentId: string, dx: number, dy: number) {
130
+ if (!dx && !dy) return
131
+ for (const child of blocks.value) {
132
+ if (child.parentId !== parentId) continue
133
+ child.position = { x: child.position.x + dx, y: child.position.y + dy }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Local-only geometry update during an active border drag — the resize counterpart of
139
+ * {@link previewMove}, and for the same reason: persisting every pointer move would let an
140
+ * out-of-order response land a stale size after the user let go. Takes ABSOLUTE bounds and
141
+ * derives the origin delta itself, so a caller can drive it from a running drag without
142
+ * tracking what it has already applied. {@link resizeBlock} commits the final bounds once.
143
+ */
144
+ function previewResize(
145
+ id: string,
146
+ position: { x: number; y: number },
147
+ size?: { w: number; h: number },
148
+ ) {
149
+ const b = getBlock(id)
150
+ if (!b) return
151
+ shiftChildren(id, b.position.x - position.x, b.position.y - position.y)
152
+ b.position = position
153
+ b.size = size
154
+ }
155
+
156
+ /**
157
+ * Commit a border-drag resize: ONE call carrying both halves of the geometry, because only an
158
+ * operation that sees the origin delta can translate the container's children with it (see
159
+ * `BoardService.resizeBlock`). `from` is the pre-drag geometry — a rejected resize replays it
160
+ * through {@link previewResize}, which undoes the child translation by the same arithmetic that
161
+ * applied it, so a failure can't leave the contents offset from a box the server never stored.
162
+ */
163
+ async function resizeBlock(
164
+ id: string,
165
+ bounds: { position: { x: number; y: number }; size: { w: number; h: number } },
166
+ from: { position: { x: number; y: number }; size?: { w: number; h: number } },
167
+ ) {
168
+ const b = getBlock(id)
169
+ if (!b) return
170
+ previewResize(id, bounds.position, bounds.size)
171
+ try {
172
+ upsert(await api.resizeBlock(useWorkspaceStore().requireId(), id, bounds))
173
+ } catch (e) {
174
+ previewResize(id, from.position, from.size)
175
+ toast.add({
176
+ title: tr('board.toast.resizeFailed'),
177
+ description: e instanceof Error ? e.message : String(e),
178
+ icon: 'i-lucide-triangle-alert',
179
+ color: 'error',
180
+ })
181
+ }
182
+ }
183
+
123
184
  /** Patch the user-editable fields of a block (title, features, threshold…). */
124
185
  async function updateBlock(id: string, patch: UpdateBlockInput) {
125
186
  const b = getBlock(id)
@@ -196,6 +257,8 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
196
257
  reparentBlock,
197
258
  previewMove,
198
259
  moveBlock,
260
+ previewResize,
261
+ resizeBlock,
199
262
  updateBlock,
200
263
  toggleDependency,
201
264
  removeDependency,
@@ -310,6 +310,57 @@ describe('board store optimistic rollback', () => {
310
310
  expect(store.getBlock('t1')?.description).toBe('keep')
311
311
  })
312
312
 
313
+ it('previewResize translates the children when the drag moves the content origin', () => {
314
+ // A child's position is relative to its container's content origin, so growing the frame
315
+ // 40px west (origin -40) has to move every direct child +40 or the whole content slides with
316
+ // the border. A grandchild rides its module and must NOT move on its own.
317
+ const store = useBoardStore()
318
+ store.hydrate([
319
+ frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
320
+ moduleBlock('m1', 'f1', { position: { x: 20, y: 30 } }),
321
+ task('t1', 'f1', { position: { x: 10, y: 20 } }),
322
+ task('t2', 'm1', { position: { x: 5, y: 5 } }),
323
+ ])
324
+ store.previewResize('f1', { x: 60, y: 100 }, { w: 640, h: 400 })
325
+ expect(store.getBlock('t1')?.position).toEqual({ x: 50, y: 20 })
326
+ expect(store.getBlock('m1')?.position).toEqual({ x: 60, y: 30 })
327
+ expect(store.getBlock('t2')?.position).toEqual({ x: 5, y: 5 })
328
+ })
329
+
330
+ it('previewResize leaves the children alone when only the far border moved', () => {
331
+ const store = useBoardStore()
332
+ store.hydrate([
333
+ frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
334
+ task('t1', 'f1', { position: { x: 10, y: 20 } }),
335
+ ])
336
+ store.previewResize('f1', { x: 100, y: 100 }, { w: 700, h: 500 })
337
+ expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
338
+ expect(store.getBlock('f1')?.size).toEqual({ w: 700, h: 500 })
339
+ })
340
+
341
+ it('resizeBlock rolls the bounds AND the child translation back when the API rejects', async () => {
342
+ // The rollback has to undo both halves: a restored box with its contents still offset is the
343
+ // one failure mode that looks fine until the next refresh moves everything.
344
+ vi.stubGlobal('useApi', () => ({
345
+ resizeBlock: () => Promise.reject(new Error('conflict')),
346
+ }))
347
+ setActivePinia(createPinia())
348
+ useWorkspaceStore().workspaceId = 'ws1'
349
+ const store = useBoardStore()
350
+ store.hydrate([
351
+ frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
352
+ task('t1', 'f1', { position: { x: 10, y: 20 } }),
353
+ ])
354
+ await store.resizeBlock(
355
+ 'f1',
356
+ { position: { x: 60, y: 70 }, size: { w: 640, h: 430 } },
357
+ { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } },
358
+ )
359
+ expect(store.getBlock('f1')?.position).toEqual({ x: 100, y: 100 })
360
+ expect(store.getBlock('f1')?.size).toEqual({ w: 600, h: 400 })
361
+ expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
362
+ })
363
+
313
364
  it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
314
365
  vi.stubGlobal('useApi', () => ({
315
366
  reparentBlock: async (
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { usePipelinesStore } from '~/stores/pipelines'
3
+
4
+ /**
5
+ * The per-step output-token ceiling rides the shared `StepOptions` bag, so its helpers owe the
6
+ * same normalization every other field in there follows: merge rather than clobber, and drop the
7
+ * whole entry once the bag empties. That last part is what keeps an all-default pipeline from
8
+ * persisting a `step_options` array of empty objects.
9
+ */
10
+ describe('pipelines store — per-step output budget', () => {
11
+ it('sets, reads and clears a draft step’s ceiling', () => {
12
+ const pipelines = usePipelinesStore()
13
+ pipelines.addToDraft('doc-researcher')
14
+ expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
15
+
16
+ pipelines.setDraftMaxOutputTokens(0, 24_000)
17
+ expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
18
+ expect(pipelines.draftStepOptions[0]).toEqual({ maxOutputTokens: 24_000 })
19
+
20
+ // Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
21
+ // so a step back on the inherited budget persists no options at all.
22
+ pipelines.setDraftMaxOutputTokens(0, undefined)
23
+ expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
24
+ expect(pipelines.draftStepOptions[0]).toBeNull()
25
+ })
26
+
27
+ it('merges with the other options on the step rather than clobbering the bag', () => {
28
+ const pipelines = usePipelinesStore()
29
+ pipelines.addToDraft('requirements-review')
30
+ pipelines.toggleDraftAutoRecommend(0)
31
+ expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
32
+
33
+ pipelines.setDraftMaxOutputTokens(0, 12_000)
34
+ expect(pipelines.draftStepOptions[0]).toEqual({
35
+ autoRecommend: false,
36
+ maxOutputTokens: 12_000,
37
+ })
38
+
39
+ // And clearing one field leaves the other standing (the entry stays, since the bag is not empty).
40
+ pipelines.setDraftMaxOutputTokens(0, undefined)
41
+ expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
42
+ })
43
+
44
+ it('keeps each step’s ceiling independent', () => {
45
+ const pipelines = usePipelinesStore()
46
+ pipelines.addToDraft('doc-researcher')
47
+ pipelines.addToDraft('doc-outliner')
48
+
49
+ pipelines.setDraftMaxOutputTokens(0, 24_000)
50
+ pipelines.setDraftMaxOutputTokens(1, 10_000)
51
+
52
+ expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
53
+ expect(pipelines.draftMaxOutputTokens(1)).toBe(10_000)
54
+ })
55
+ })
@@ -139,6 +139,27 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
139
139
  draftStepOptions.value[index] = Object.keys(next).length ? next : null
140
140
  }
141
141
 
142
+ /**
143
+ * The output-token ceiling pinned on the draft step at `index`, or undefined when the step
144
+ * inherits (the workspace's per-kind setting, else the deployment default).
145
+ */
146
+ function draftMaxOutputTokens(index: number): number | undefined {
147
+ return draftStepOptions.value[index]?.maxOutputTokens
148
+ }
149
+
150
+ /**
151
+ * Set (or clear) this step's own output-token ceiling. Merges into the step's `StepOptions`
152
+ * bag rather than clobbering it; clearing drops the field and, if the bag empties, the whole
153
+ * entry — so a step back on the inherited budget persists no options at all, exactly like the
154
+ * other fields here.
155
+ */
156
+ function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
157
+ const next: StepOptions = { ...draftStepOptions.value[index] }
158
+ if (maxOutputTokens != null) next.maxOutputTokens = maxOutputTokens
159
+ else delete next.maxOutputTokens
160
+ draftStepOptions.value[index] = Object.keys(next).length ? next : null
161
+ }
162
+
142
163
  return {
143
164
  toggleDraftGating,
144
165
  toggleDraftConsensus,
@@ -153,5 +174,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
153
174
  toggleDraftAutoRecommend,
154
175
  draftSkillId,
155
176
  setDraftSkillId,
177
+ draftMaxOutputTokens,
178
+ setDraftMaxOutputTokens,
156
179
  }
157
180
  }
@@ -0,0 +1,12 @@
1
+ // Per-workspace, per-agent-kind generation settings, mirroring `@cat-factory/contracts`
2
+ // (agent-settings.ts). Today one knob: the output-token ceiling a kind's inline calls run under.
3
+ // A kind absent from the store inherits the deployment routing default, and a pipeline step's own
4
+ // `stepOptions.maxOutputTokens` still overrides whatever is set here.
5
+ //
6
+ // All wire shapes are sourced from @cat-factory/contracts (single source of truth).
7
+
8
+ export type {
9
+ UpdateWorkspaceAgentSettingsInput,
10
+ WorkspaceAgentSettings,
11
+ } from '@cat-factory/contracts'
12
+ export { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '@cat-factory/contracts'
@@ -15,6 +15,7 @@ export type {
15
15
  RequirementReviewItem,
16
16
  RequirementReviewStatus,
17
17
  ResolveRequirementsExceededChoice,
18
+ RecommendationSource,
18
19
  RecommendationStatus,
19
20
  RequirementRecommendation,
20
21
  RequirementReview,
@@ -2313,6 +2313,7 @@
2313
2313
  "updateFailed": "Änderungen konnten nicht gespeichert werden",
2314
2314
  "epicFailed": "Epic konnte nicht geändert werden",
2315
2315
  "moveFailed": "Verschieben nicht möglich",
2316
+ "resizeFailed": "Größe konnte nicht geändert werden",
2316
2317
  "deleteFailed": "Löschen nicht möglich",
2317
2318
  "linkFailed": "Aufgaben konnten nicht verknüpft werden",
2318
2319
  "unlinkFailed": "Abhängigkeit konnte nicht entfernt werden",
@@ -2566,7 +2567,6 @@
2566
2567
  "mergedOfTotal": "{merged}/{total} gemergt",
2567
2568
  "noTasksYet": "Noch keine Aufgaben",
2568
2569
  "prCount": "{count} PR",
2569
- "implemented": "{merged}/{total} implementiert",
2570
2570
  "prReadyCount": "{count} PR bereit",
2571
2571
  "taskCount": "{count} Aufgabe | {count} Aufgaben",
2572
2572
  "moduleCount": "{count} Modul | {count} Module",
@@ -3750,6 +3750,13 @@
3750
3750
  "deleteFailed": "Pipeline konnte nicht gelöscht werden",
3751
3751
  "removeFailed": "Pipeline konnte nicht entfernt werden"
3752
3752
  }
3753
+ },
3754
+ "outputBudget": {
3755
+ "stepLabel": "Ausgabebudget",
3756
+ "kindLabel": "Ausgabebudget",
3757
+ "kindHint": "Gilt für jeden Lauf dieses Agenten. Ein Pipeline-Schritt kann es überschreiben.",
3758
+ "inherits": "Übernommen",
3759
+ "inheritsValue": "Übernommen ({tokens})"
3753
3760
  }
3754
3761
  },
3755
3762
  "agentPrompt": {
@@ -3784,7 +3791,8 @@
3784
3791
  "reverted": "Zurück zum Standardprompt",
3785
3792
  "saveFailed": "Prompt konnte nicht gespeichert werden",
3786
3793
  "loadFailed": "Prompt konnte nicht geladen werden",
3787
- "conflict": "Jemand anderes hat diesen Prompt geändert. Neu geladen - wenden Sie Ihre Änderung erneut an."
3794
+ "conflict": "Jemand anderes hat diesen Prompt geändert. Neu geladen - wenden Sie Ihre Änderung erneut an.",
3795
+ "budgetFailed": "Ausgabebudget konnte nicht gespeichert werden"
3788
3796
  }
3789
3797
  },
3790
3798
  "agentTier": {
@@ -3864,6 +3872,12 @@
3864
3872
  "recommendationProgress": "{ready} / {total} bereit",
3865
3873
  "generatingSuggestion": "Ein fundierter Vorschlag wird generiert…",
3866
3874
  "currentStandard": "Aktueller Standard: {title}",
3875
+ "grounding": {
3876
+ "standard": "Team-Standard",
3877
+ "project-spec": "Projektspezifikation",
3878
+ "web": "Webquelle",
3879
+ "general-practice": "Allgemeine Praxis"
3880
+ },
3867
3881
  "accept": "Annehmen",
3868
3882
  "reject": "Ablehnen",
3869
3883
  "reRequestPlaceholder": "Nach einer anderen Empfehlung fragen…",
@@ -152,6 +152,7 @@
152
152
  "updateFailed": "Could not save changes",
153
153
  "epicFailed": "Could not change epic",
154
154
  "moveFailed": "Could not move",
155
+ "resizeFailed": "Could not resize",
155
156
  "deleteFailed": "Could not delete",
156
157
  "linkFailed": "Could not link tasks",
157
158
  "unlinkFailed": "Could not remove dependency",
@@ -417,7 +418,6 @@
417
418
  "mergedOfTotal": "{merged}/{total} merged",
418
419
  "noTasksYet": "No tasks yet",
419
420
  "prCount": "{count} PR",
420
- "implemented": "{merged}/{total} implemented",
421
421
  "prReadyCount": "{count} PR ready",
422
422
  "taskCount": "{count} task | {count} tasks",
423
423
  "@taskCount": {
@@ -4188,6 +4188,13 @@
4188
4188
  "deleteFailed": "Could not delete pipeline",
4189
4189
  "removeFailed": "Could not remove pipeline"
4190
4190
  }
4191
+ },
4192
+ "outputBudget": {
4193
+ "stepLabel": "Output budget",
4194
+ "kindLabel": "Output budget",
4195
+ "kindHint": "Applies to every run of this agent. A pipeline step can override it.",
4196
+ "inherits": "Inherited",
4197
+ "inheritsValue": "Inherited ({tokens})"
4191
4198
  }
4192
4199
  },
4193
4200
  "agentPrompt": {
@@ -4231,7 +4238,8 @@
4231
4238
  "reverted": "Back to the built-in prompt",
4232
4239
  "saveFailed": "Couldn't save the prompt",
4233
4240
  "loadFailed": "Couldn't load the prompt",
4234
- "conflict": "Someone else changed this prompt. Reloaded - re-apply your edit."
4241
+ "conflict": "Someone else changed this prompt. Reloaded - re-apply your edit.",
4242
+ "budgetFailed": "Could not save the output budget"
4235
4243
  }
4236
4244
  },
4237
4245
  "agentTier": {
@@ -4484,6 +4492,16 @@
4484
4492
  "recommendationProgress": "{ready} / {total} ready",
4485
4493
  "generatingSuggestion": "Generating a grounded suggestion…",
4486
4494
  "currentStandard": "Current standard: {title}",
4495
+ "grounding": {
4496
+ "standard": "Team standard",
4497
+ "@standard": "Badge on a suggested answer: it came from the team's own best-practice standard. Noun phrase, short — fits a small badge.",
4498
+ "project-spec": "Project spec",
4499
+ "@project-spec": "Badge on a suggested answer: it came from the project's own committed specification documents. Noun phrase, short.",
4500
+ "web": "Web source",
4501
+ "@web": "Badge on a suggested answer: it came from a web search result. Noun phrase, short.",
4502
+ "general-practice": "General practice",
4503
+ "@general-practice": "Badge on a suggested answer: it rests only on the model's general knowledge, with no team standard, project spec or source behind it — the weakest grounding, so the wording should not sound authoritative. Noun phrase, short."
4504
+ },
4487
4505
  "accept": "Accept",
4488
4506
  "reject": "Reject",
4489
4507
  "reRequestPlaceholder": "Ask for a different recommendation…",