@cat-factory/app 0.296.1 → 0.296.3
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/README.md +4 -3
- package/app/components/board/AddTaskModal.vue +2 -2
- package/app/components/board/BoardCanvas.vue +4 -92
- package/app/components/board/CreateInitiativeModal.vue +2 -2
- package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -12
- package/app/components/foundational/FoundationalServiceSources.vue +2 -11
- package/app/components/fragments/FragmentLibraryManager.vue +3 -12
- package/app/components/layout/AccountAuditLog.logic.ts +2 -2
- package/app/components/panels/ResultWindowShell.logic.spec.ts +7 -22
- package/app/components/panels/StepToolServers.logic.ts +2 -2
- package/app/components/settings/ConsensusGroupsSection.vue +1 -4
- package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +0 -1
- package/app/components/settings/OpenRouterCatalogPanel.vue +6 -2
- package/app/components/skills/SkillLibraryManager.vue +3 -12
- package/app/components/tutorial/TutorialOverlay.logic.ts +2 -2
- package/app/composables/api/client.spec.ts +0 -5
- package/app/composables/useBusyRows.ts +22 -0
- package/app/composables/useFrameResize.ts +0 -5
- package/app/composables/useSemanticZoom.ts +1 -1
- package/app/modular/panels/inspector.ts +1 -1
- package/app/modular/result-views.ts +4 -3
- package/app/stores/board/mutations.ts +1 -14
- package/app/stores/observability/agentContext.ts +1 -8
- package/app/stores/observability/toolCalls.ts +1 -8
- package/app/stores/observability/withFlag.ts +9 -0
- package/app/utils/blockRects.ts +1 -1
- package/app/utils/boardWakeGate.ts +1 -1
- package/app/utils/catalog.ts +1 -14
- package/app/utils/descriptorFields.spec.ts +0 -45
- package/app/utils/descriptorFields.ts +1 -13
- package/app/utils/dnd.ts +1 -25
- package/app/utils/initiative.ts +1 -1
- package/app/utils/settlingLoop.ts +1 -1
- package/app/utils/taskExpansionRanking.spec.ts +8 -17
- package/app/utils/tutorial.ts +1 -1
- package/app/utils/uiMode.spec.ts +0 -1
- package/app/utils/uiRole.ts +1 -1
- package/i18n/locales/de.json +1 -7
- package/i18n/locales/en.json +1 -7
- package/i18n/locales/es.json +1 -7
- package/i18n/locales/fr.json +1 -7
- package/i18n/locales/he.json +1 -7
- package/i18n/locales/it.json +1 -7
- package/i18n/locales/ja.json +1 -7
- package/i18n/locales/pl.json +1 -7
- package/i18n/locales/tr.json +1 -7
- package/i18n/locales/uk.json +1 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -203,9 +203,10 @@ reaches the wire. **Every string a descriptor carries is deployment-authored Eng
|
|
|
203
203
|
verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
|
|
204
204
|
platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
|
|
205
205
|
a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
|
|
206
|
-
(`
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
(`setDescriptorValue` / `setDescriptorCheckbox` / `toggleDescriptorGroupValue` for one edit; the
|
|
207
|
+
initial values come straight from contracts' `descriptorFieldDefaults`, the same helper the server
|
|
208
|
+
folds in at the creation door): what an edit freezes on an entity is what a unit test must be able
|
|
209
|
+
to reach, and a rule inside a component is only reachable by mounting one.
|
|
209
210
|
|
|
210
211
|
Mirroring the server's check leaves one refusal still reachable, deliberately: the deployment can
|
|
211
212
|
re-register the descriptor while the dialog sits open, so a create can come back `422` with
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
sanitizeDescriptorFields,
|
|
40
40
|
validateDescriptorFields,
|
|
41
41
|
} from '@cat-factory/contracts'
|
|
42
|
-
import {
|
|
42
|
+
import { descriptorFieldDefaults } from '@cat-factory/contracts'
|
|
43
43
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
44
44
|
import { buildTaskTypePickerRows } from '~/utils/taskTypePicker'
|
|
45
45
|
|
|
@@ -493,7 +493,7 @@ watch(taskType, (next) => {
|
|
|
493
493
|
const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
|
|
494
494
|
// A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
|
|
495
495
|
// to whatever defaults the new type declares.
|
|
496
|
-
customFieldValues.value =
|
|
496
|
+
customFieldValues.value = descriptorFieldDefaults(custom?.fields ?? [])
|
|
497
497
|
// An unresolvable preset leaves the current selection alone rather than blanking it: a type
|
|
498
498
|
// switch is an edit to a form the user is already filling in, not a reset.
|
|
499
499
|
const preset = defaultPipelineIdFor(next)
|
|
@@ -5,7 +5,7 @@ import BlockNode from './nodes/BlockNode.vue'
|
|
|
5
5
|
import EpicNode from './nodes/EpicNode.vue'
|
|
6
6
|
import TaskDependencyEdges from './TaskDependencyEdges.vue'
|
|
7
7
|
import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
|
|
8
|
-
import {
|
|
8
|
+
import { blockIdFromEvent } from '~/utils/dnd'
|
|
9
9
|
import { BOARD_FLOW_ID, BOARD_MIN_ZOOM, BOARD_MAX_ZOOM } from '~/composables/useBoardFlow'
|
|
10
10
|
import { provideBoardActivity } from '~/composables/useBoardActivity'
|
|
11
11
|
import { useTaskExpansion } from '~/composables/useTaskExpansion'
|
|
@@ -18,21 +18,17 @@ import { boardPanMode } from '~/utils/boardPanMode'
|
|
|
18
18
|
import { createBoardNodeProjection } from './BoardCanvas.logic'
|
|
19
19
|
|
|
20
20
|
const board = useBoardStore()
|
|
21
|
-
const pipelines = usePipelinesStore()
|
|
22
|
-
const execution = useExecutionStore()
|
|
23
21
|
const ui = useUiStore()
|
|
24
22
|
const github = useGitHubStore()
|
|
25
|
-
const toast = useToast()
|
|
26
23
|
// The drop resolves its target task at the moment of the drop, so the sandbox reading is a
|
|
27
24
|
// function of that id rather than a computed over a bound block.
|
|
28
|
-
const { forcedFor } = useDryRunPolicy()
|
|
29
25
|
const access = useWorkspaceAccess()
|
|
30
26
|
const { t } = useI18n()
|
|
31
27
|
|
|
32
|
-
const { onNodeDragStop, onViewportChange
|
|
28
|
+
const { onNodeDragStop, onViewportChange } = useVueFlow(BOARD_FLOW_ID)
|
|
33
29
|
const { draggingId } = useBlockDrag()
|
|
34
30
|
const { hoveredFrameId } = useFrameStacking()
|
|
35
|
-
const {
|
|
31
|
+
const { focusFrame } = useFramePlacement()
|
|
36
32
|
// The board's "no two top-level nodes overlap" invariant: whenever a frame comes to overlap a
|
|
37
33
|
// neighbour (dragged onto it, grown into it by a border drag, or grown by its first task), the two
|
|
38
34
|
// are bounced apart. Every client draws that correction; only the one whose own gesture caused the
|
|
@@ -127,94 +123,10 @@ function onNodeDoubleClick({ event, node }: NodeMouseEvent) {
|
|
|
127
123
|
function onPaneClick() {
|
|
128
124
|
ui.select(null)
|
|
129
125
|
}
|
|
130
|
-
|
|
131
|
-
// ---- palette drag & drop onto the canvas ----------------------------------
|
|
132
|
-
function onDragOver(event: DragEvent) {
|
|
133
|
-
event.preventDefault()
|
|
134
|
-
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function onDrop(event: DragEvent) {
|
|
138
|
-
event.preventDefault()
|
|
139
|
-
const payload = readDndPayload(event)
|
|
140
|
-
if (!payload) return
|
|
141
|
-
|
|
142
|
-
if (payload.kind === 'block') {
|
|
143
|
-
// Drop where the cursor is, but nudge off any existing frame it lands on so a new
|
|
144
|
-
// frame never overlaps a neighbour; then centre the camera on it.
|
|
145
|
-
const dropped = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
|
|
146
|
-
const position = freeFramePosition({ near: dropped })
|
|
147
|
-
try {
|
|
148
|
-
const block = await board.addBlock(payload.blockType, position)
|
|
149
|
-
ui.select(block.id)
|
|
150
|
-
await focusFrame(block.id)
|
|
151
|
-
} catch {
|
|
152
|
-
toast.add({
|
|
153
|
-
title: t('board.canvas.addBlockFailedTitle'),
|
|
154
|
-
description: t('board.canvas.addBlockFailedBody'),
|
|
155
|
-
color: 'error',
|
|
156
|
-
})
|
|
157
|
-
}
|
|
158
|
-
return
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (payload.kind === 'pipeline') {
|
|
162
|
-
// Pipelines run against tasks, not frames. The nearest [data-block-id] under
|
|
163
|
-
// the cursor is the task card when dropped inside an expanded frame.
|
|
164
|
-
const blockId = blockIdFromEvent(event)
|
|
165
|
-
const target = blockId ? board.getBlock(blockId) : undefined
|
|
166
|
-
const pipeline = pipelines.getPipeline(payload.pipelineId)
|
|
167
|
-
// Unknown pipeline id is an internal glitch (nothing the user can act on); a drop
|
|
168
|
-
// onto blank canvas / a non-block, though, needs the same "aim at a task" nudge the
|
|
169
|
-
// wrong-level path gives — otherwise the drop just vanishes (UX-07).
|
|
170
|
-
if (!pipeline) return
|
|
171
|
-
if (!target) {
|
|
172
|
-
toast.add({
|
|
173
|
-
title: t('board.canvas.dropOntoTaskTitle'),
|
|
174
|
-
description: t('board.canvas.dropOntoTaskBody'),
|
|
175
|
-
})
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
if (target.level !== 'task') {
|
|
179
|
-
toast.add({
|
|
180
|
-
title: t('board.canvas.dropOntoTaskTitle'),
|
|
181
|
-
description: t('board.canvas.dropOntoTaskBody'),
|
|
182
|
-
})
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
if (!board.isRunnable(target.id)) {
|
|
186
|
-
toast.add({
|
|
187
|
-
title: t('board.canvas.taskBlockedTitle'),
|
|
188
|
-
description: t('board.canvas.taskBlockedBody'),
|
|
189
|
-
})
|
|
190
|
-
return
|
|
191
|
-
}
|
|
192
|
-
// A dropped pipeline starts live and has nothing to ask for, but a preset that sandboxes the
|
|
193
|
-
// dropper's role means this run merges nothing. Said HERE, because the drop is the only
|
|
194
|
-
// moment this surface has: the ordinary start stays silent (it is already visible on the
|
|
195
|
-
// card it landed on), and only the sandbox, which is not, earns a toast.
|
|
196
|
-
if (forcedFor(target.id)) {
|
|
197
|
-
toast.add({
|
|
198
|
-
title: t('board.dryRunToast.title'),
|
|
199
|
-
description: t('board.dryRunToast.body', { name: pipeline.name }),
|
|
200
|
-
color: 'warning',
|
|
201
|
-
icon: 'i-lucide-shield',
|
|
202
|
-
})
|
|
203
|
-
}
|
|
204
|
-
execution.start(target.id, pipeline)
|
|
205
|
-
ui.select(target.id)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
126
|
</script>
|
|
209
127
|
|
|
210
128
|
<template>
|
|
211
|
-
<div
|
|
212
|
-
ref="boardEl"
|
|
213
|
-
data-testid="board-canvas"
|
|
214
|
-
class="relative h-full w-full"
|
|
215
|
-
@drop="onDrop"
|
|
216
|
-
@dragover="onDragOver"
|
|
217
|
-
>
|
|
129
|
+
<div ref="boardEl" data-testid="board-canvas" class="relative h-full w-full">
|
|
218
130
|
<VueFlow
|
|
219
131
|
:id="BOARD_FLOW_ID"
|
|
220
132
|
:nodes="nodes"
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
validateInitiativePresetInputs,
|
|
22
22
|
} from '@cat-factory/contracts'
|
|
23
23
|
import type { InitiativePresetInputs, InitiativePresetInputValue } from '~/types/domain'
|
|
24
|
-
import {
|
|
24
|
+
import { descriptorFieldDefaults } from '@cat-factory/contracts'
|
|
25
25
|
import { GENERIC_PRESET_ID } from '~/stores/initiative'
|
|
26
26
|
import DescriptorFields from '~/components/common/DescriptorFields.vue'
|
|
27
27
|
import ContextAttachmentFields from '~/components/context/ContextAttachmentFields.vue'
|
|
@@ -71,7 +71,7 @@ let probeSeq = 0
|
|
|
71
71
|
/** Seed the form to the selected preset's descriptor defaults, then fire its detection probe. */
|
|
72
72
|
function applyPreset(): void {
|
|
73
73
|
const descriptor = selectedPreset.value
|
|
74
|
-
inputs.value = descriptor ?
|
|
74
|
+
inputs.value = descriptor ? descriptorFieldDefaults(descriptor.fields) : {}
|
|
75
75
|
void runProbe()
|
|
76
76
|
}
|
|
77
77
|
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
// A contract body is fetched only when a human expands one, through the SAME lazy read a consumer
|
|
15
15
|
// dispatch makes — so what is inspected here is what an agent would be given, and merely opening
|
|
16
16
|
// the catalog transfers no documents.
|
|
17
|
-
import { computed,
|
|
17
|
+
import { computed, ref } from 'vue'
|
|
18
|
+
import { useBusyRows } from '~/composables/useBusyRows'
|
|
18
19
|
import type { ApiContractFormat, FoundationalServiceTier } from '~/types/domain'
|
|
19
20
|
import { useFoundationalServicesStore } from '~/stores/foundationalServices'
|
|
20
21
|
import FoundationalContractSummary from '~/components/foundational/FoundationalContractSummary.vue'
|
|
@@ -45,17 +46,7 @@ const tierColor = {
|
|
|
45
46
|
workspace: 'primary',
|
|
46
47
|
} as const satisfies Record<FoundationalServiceTier, string>
|
|
47
48
|
|
|
48
|
-
const
|
|
49
|
-
const rowBusy = (key: string) => busyRows.has(key)
|
|
50
|
-
async function withRow(key: string, fn: () => Promise<void>) {
|
|
51
|
-
if (busyRows.has(key)) return
|
|
52
|
-
busyRows.add(key)
|
|
53
|
-
try {
|
|
54
|
-
await fn()
|
|
55
|
-
} finally {
|
|
56
|
-
busyRows.delete(key)
|
|
57
|
-
}
|
|
58
|
-
}
|
|
49
|
+
const { rowBusy, withRow } = useBusyRows()
|
|
59
50
|
|
|
60
51
|
/** Service ids whose contract documents the user has expanded. */
|
|
61
52
|
const expanded = ref<string[]>([])
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// Mirrors the skill library's sources UI: with the GitHub App connected the user searches a repo
|
|
15
15
|
// and browses to a path; otherwise the manual owner/name fields are the fallback.
|
|
16
16
|
import { computed, reactive, ref, watch } from 'vue'
|
|
17
|
+
import { useBusyRows } from '~/composables/useBusyRows'
|
|
17
18
|
import type {
|
|
18
19
|
FolderScanCoverage,
|
|
19
20
|
FoundationalServiceOwnerKind,
|
|
@@ -44,17 +45,7 @@ const { confirm } = useConfirm()
|
|
|
44
45
|
const githubReady = computed(() => github.available === true && github.connected)
|
|
45
46
|
|
|
46
47
|
// Per-row in-flight tracking so only the control that triggered an action spins.
|
|
47
|
-
const
|
|
48
|
-
const rowBusy = (key: string) => busyRows.has(key)
|
|
49
|
-
async function withRow(key: string, fn: () => Promise<void>) {
|
|
50
|
-
if (busyRows.has(key)) return
|
|
51
|
-
busyRows.add(key)
|
|
52
|
-
try {
|
|
53
|
-
await fn()
|
|
54
|
-
} finally {
|
|
55
|
-
busyRows.delete(key)
|
|
56
|
-
}
|
|
57
|
-
}
|
|
48
|
+
const { rowBusy, withRow } = useBusyRows()
|
|
58
49
|
|
|
59
50
|
// ---- link a repo source ----------------------------------------------------
|
|
60
51
|
const mode = ref<FoundationalServiceSourceMode>('directory')
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// the merged catalog (built-in ∪ account ∪ workspace) an agent is selected from per
|
|
7
7
|
// run. The account scope has no resolved/merged catalog and fetches document
|
|
8
8
|
// fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
|
|
9
|
-
import { computed, nextTick,
|
|
9
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
10
|
+
import { useBusyRows } from '~/composables/useBusyRows'
|
|
10
11
|
import type {
|
|
11
12
|
DocumentSourceKind,
|
|
12
13
|
FragmentOwnerKind,
|
|
@@ -108,17 +109,7 @@ function tabLabel(which: Tab): string {
|
|
|
108
109
|
// Per-row / per-form in-flight tracking. The store's single `library.loading` flag
|
|
109
110
|
// drove every row's button at once (UX-29) and cross-spun the add/link forms; key
|
|
110
111
|
// each async action so only the control that triggered it shows a spinner.
|
|
111
|
-
const
|
|
112
|
-
const rowBusy = (key: string) => busyRows.has(key)
|
|
113
|
-
async function withRow(key: string, fn: () => Promise<void>) {
|
|
114
|
-
if (busyRows.has(key)) return
|
|
115
|
-
busyRows.add(key)
|
|
116
|
-
try {
|
|
117
|
-
await fn()
|
|
118
|
-
} finally {
|
|
119
|
-
busyRows.delete(key)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
112
|
+
const { rowBusy, withRow } = useBusyRows()
|
|
122
113
|
const creating = ref(false)
|
|
123
114
|
const linkingDoc = ref(false)
|
|
124
115
|
const linkingSource = ref(false)
|
|
@@ -47,7 +47,7 @@ export type Translate = (key: string, params?: Record<string, string>) => string
|
|
|
47
47
|
* Derived from the contract rather than re-listed, so an action whose fields change cannot leave
|
|
48
48
|
* this behind: the `Record` it reads is exhaustive over the same picklist `ACTION_KEYS` is.
|
|
49
49
|
*/
|
|
50
|
-
|
|
50
|
+
function detailParams(
|
|
51
51
|
action: AuditAction,
|
|
52
52
|
details: AuditEventWire['details'],
|
|
53
53
|
none: string,
|
|
@@ -61,7 +61,7 @@ export function detailParams(
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/** Who the action was performed ON: the resolved name, else the raw id. */
|
|
64
|
-
|
|
64
|
+
function targetLabel(event: AuditEventWire): string {
|
|
65
65
|
return event.targetName ?? event.targetId
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
2
|
import { resolve } from 'node:path'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import {
|
|
5
|
-
PROSE_MEASURE_CLASS,
|
|
6
|
-
RESULT_WINDOW_WIDTH_CLASS,
|
|
7
|
-
type ResultWindowWidth,
|
|
8
|
-
} from './ResultWindowShell.logic'
|
|
4
|
+
import { PROSE_MEASURE_CLASS, type ResultWindowWidth } from './ResultWindowShell.logic'
|
|
9
5
|
|
|
10
6
|
// The width decision is a per-window LAYOUT judgement that lives on each window's
|
|
11
7
|
// `<ResultWindowShell width="…">`, which means nothing in the type system can ask a window author
|
|
@@ -165,22 +161,11 @@ describe('result-window width buckets', () => {
|
|
|
165
161
|
})
|
|
166
162
|
})
|
|
167
163
|
|
|
168
|
-
describe('
|
|
169
|
-
it('
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// Not a bigger number: the panel's `w-full` spans the backdrop and the variant's gutter
|
|
175
|
-
// (`m-4` / `p-4`) is the only inset. A cap here would reintroduce the indefensible number
|
|
176
|
-
// `full` exists to avoid.
|
|
177
|
-
full: 'max-w-none',
|
|
178
|
-
})
|
|
179
|
-
})
|
|
180
|
-
|
|
181
|
-
it('shares one measure with the step reader', () => {
|
|
182
|
-
// `AgentStepDetail` reads prose at `mx-auto max-w-3xl`; the windows must not hold a second
|
|
183
|
-
// opinion about how wide prose should be.
|
|
184
|
-
expect(PROSE_MEASURE_CLASS).toBe('max-w-3xl')
|
|
164
|
+
describe('PROSE_MEASURE_CLASS', () => {
|
|
165
|
+
it('is the measure the step reader already uses', () => {
|
|
166
|
+
// `AgentStepDetail` reads prose at its own measure; the windows must not hold a second
|
|
167
|
+
// opinion about how wide prose should be, so the constant is checked against that source.
|
|
168
|
+
const stepReader = readFileSync(resolve(componentsDir, 'panels/AgentStepDetail.vue'), 'utf8')
|
|
169
|
+
expect(stepReader).toContain(PROSE_MEASURE_CLASS)
|
|
185
170
|
})
|
|
186
171
|
})
|
|
@@ -81,7 +81,7 @@ export const KNOWN_REASONS = toolServerUnavailableReasonSchema.options
|
|
|
81
81
|
* it were a translation key, taking the retired-member path away from exactly the case it exists
|
|
82
82
|
* for. Narrowing at the boundary keeps the exhaustive `Record` compile-time guard intact.
|
|
83
83
|
*/
|
|
84
|
-
|
|
84
|
+
function isKnownReason(reason: string): reason is ToolServerUnavailableReason {
|
|
85
85
|
return (KNOWN_REASONS as readonly string[]).includes(reason)
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -205,7 +205,7 @@ export const KNOWN_OBSERVED_STATUSES = toolServerObservedStatusSchema.options
|
|
|
205
205
|
* A predicate rather than a truthiness check on the lookup, for the reason {@link isKnownReason}
|
|
206
206
|
* gives: an `Object.prototype` member name reads back as a truthy non-key.
|
|
207
207
|
*/
|
|
208
|
-
|
|
208
|
+
function isKnownObservedStatus(status: string): status is ToolServerObservedStatus {
|
|
209
209
|
return (KNOWN_OBSERVED_STATUSES as readonly string[]).includes(status)
|
|
210
210
|
}
|
|
211
211
|
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { computed, ref } from 'vue'
|
|
13
13
|
import type { ConsensusGroup, ConsensusStrategy } from '~/types/consensus'
|
|
14
14
|
import { isSelectable } from '~/stores/models'
|
|
15
|
+
import { uid } from '~/utils/catalog'
|
|
15
16
|
|
|
16
17
|
const { t } = useI18n()
|
|
17
18
|
const groups = useConsensusGroupsStore()
|
|
@@ -52,10 +53,6 @@ interface EditorState {
|
|
|
52
53
|
const editor = ref<EditorState | null>(null)
|
|
53
54
|
const busy = ref(false)
|
|
54
55
|
|
|
55
|
-
function uid(prefix: string) {
|
|
56
|
-
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
56
|
/** A fresh group starts as a gated two-model panel — the shape the feature is for. */
|
|
60
57
|
function startCreate() {
|
|
61
58
|
editor.value = {
|
|
@@ -34,7 +34,6 @@ describe('narrowingOptionsFor', () => {
|
|
|
34
34
|
describe('INHERIT_RULE', () => {
|
|
35
35
|
it('is a non-empty value the select can carry as an item', () => {
|
|
36
36
|
expect(INHERIT_RULE).not.toBe('')
|
|
37
|
-
expect(INHERIT_RULE.length).toBeGreaterThan(0)
|
|
38
37
|
})
|
|
39
38
|
|
|
40
39
|
// It is also not one of the rules, or clearing a row would be indistinguishable from setting it.
|
|
@@ -30,13 +30,17 @@ const back = useIntegrationBack(open)
|
|
|
30
30
|
// Popular slugs offered by "Enable recommended" — these mirror the curated `openrouter`
|
|
31
31
|
// refs in the backend MODEL_CATALOG. Only the ones present in the live browse list are
|
|
32
32
|
// ticked, so a recommendation never enables a slug OpenRouter doesn't actually serve.
|
|
33
|
+
// The contributor tier of Muse Spark is deliberately absent: it is cheaper because Meta
|
|
34
|
+
// trains on the traffic, and a one-click "enable recommended" is the wrong place to make
|
|
35
|
+
// that trade on somebody's behalf.
|
|
33
36
|
const RECOMMENDED_SLUGS = [
|
|
34
|
-
'anthropic/claude-fable-5',
|
|
37
|
+
'anthropic/claude-fable-5.1',
|
|
35
38
|
'anthropic/claude-opus-5',
|
|
36
39
|
'openai/gpt-5.6-sol',
|
|
37
40
|
'openai/gpt-5.6-terra',
|
|
38
41
|
'google/gemini-3.1-pro-preview',
|
|
39
|
-
'google/gemini-3.
|
|
42
|
+
'google/gemini-3.8-flash',
|
|
43
|
+
'meta/muse-spark-1.3',
|
|
40
44
|
'deepseek/deepseek-v4-flash',
|
|
41
45
|
'moonshotai/kimi-k2.7-code',
|
|
42
46
|
'z-ai/glm-5.2',
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// synced skill catalog a pipeline `skill` step picks from. Mirrors the fragment library's
|
|
6
6
|
// repo-sources UI; when the GitHub App is connected the user searches a repo + browses to the
|
|
7
7
|
// skills directory, otherwise the manual owner/name/dir fields are the fallback.
|
|
8
|
-
import { computed,
|
|
8
|
+
import { computed, ref, watch } from 'vue'
|
|
9
|
+
import { useBusyRows } from '~/composables/useBusyRows'
|
|
9
10
|
import type { GitHubAvailableRepo } from '~/types/domain'
|
|
10
11
|
import { useSkillLibrary } from '~/stores/skillLibrary'
|
|
11
12
|
import { SKILL_GROUP_LABEL_KEYS } from '~/utils/skills'
|
|
@@ -36,17 +37,7 @@ watch(
|
|
|
36
37
|
const githubReady = computed(() => github.available === true && github.connected)
|
|
37
38
|
|
|
38
39
|
// Per-row in-flight tracking so only the control that triggered an action spins.
|
|
39
|
-
const
|
|
40
|
-
const rowBusy = (key: string) => busyRows.has(key)
|
|
41
|
-
async function withRow(key: string, fn: () => Promise<void>) {
|
|
42
|
-
if (busyRows.has(key)) return
|
|
43
|
-
busyRows.add(key)
|
|
44
|
-
try {
|
|
45
|
-
await fn()
|
|
46
|
-
} finally {
|
|
47
|
-
busyRows.delete(key)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
40
|
+
const { rowBusy, withRow } = useBusyRows()
|
|
50
41
|
|
|
51
42
|
// ---- link a repo source ----------------------------------------------------
|
|
52
43
|
const sourceRepoId = ref<number | undefined>(undefined)
|
|
@@ -82,7 +82,7 @@ export function focusLeftCard(card: Node | null, relatedTarget: EventTarget | nu
|
|
|
82
82
|
* no selector is ever built from a string that could break one, and an id that fails simply
|
|
83
83
|
* finds no anchor, which the runtime already handles as a skipped step.
|
|
84
84
|
*/
|
|
85
|
-
|
|
85
|
+
const TARGET_ID_PATTERN = /^[a-z0-9-]+$/
|
|
86
86
|
|
|
87
87
|
/** Is this a well-formed anchor id, i.e. safe to put in a selector? */
|
|
88
88
|
export function isSafeTargetId(id: string): boolean {
|
|
@@ -129,7 +129,7 @@ export function resolveSkip(
|
|
|
129
129
|
* step's target id, since the same id (`task-card`) is a canvas node on the board and a plain
|
|
130
130
|
* list row in a panel.
|
|
131
131
|
*/
|
|
132
|
-
|
|
132
|
+
const BOARD_NODE_SELECTOR = '.vue-flow__node'
|
|
133
133
|
|
|
134
134
|
/** The part of an element the reveal path needs: ancestry, and that ancestor's node id. */
|
|
135
135
|
interface RevealNode {
|
|
@@ -27,11 +27,6 @@ describe('withoutUndefinedQueryParams', () => {
|
|
|
27
27
|
expect(out.queryParams).toEqual({ blockId: 'blk_1', page: 0, all: false, q: '' })
|
|
28
28
|
})
|
|
29
29
|
|
|
30
|
-
it('drops only the undefined keys from a mixed set', () => {
|
|
31
|
-
const out = strip({ queryParams: { window: '7d', workspaceId: undefined } })
|
|
32
|
-
expect(out.queryParams).toEqual({ window: '7d' })
|
|
33
|
-
})
|
|
34
|
-
|
|
35
30
|
it('leaves other request params untouched', () => {
|
|
36
31
|
const params = {
|
|
37
32
|
pathPrefix: '/workspaces/ws_1',
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A per-row in-flight guard for lists whose rows each carry their own action buttons: a row's
|
|
5
|
+
* action runs at most once at a time, and the button it belongs to reads `rowBusy(key)` for its
|
|
6
|
+
* spinner. Keys are caller-chosen (`sync:<id>`, `unlink:<id>`), so one row can hold several
|
|
7
|
+
* independent actions.
|
|
8
|
+
*/
|
|
9
|
+
export function useBusyRows() {
|
|
10
|
+
const busyRows = reactive(new Set<string>())
|
|
11
|
+
const rowBusy = (key: string) => busyRows.has(key)
|
|
12
|
+
async function withRow(key: string, fn: () => Promise<void>) {
|
|
13
|
+
if (busyRows.has(key)) return
|
|
14
|
+
busyRows.add(key)
|
|
15
|
+
try {
|
|
16
|
+
await fn()
|
|
17
|
+
} finally {
|
|
18
|
+
busyRows.delete(key)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return { rowBusy, withRow }
|
|
22
|
+
}
|
|
@@ -35,11 +35,6 @@ const resizingId = ref<string | null>(null)
|
|
|
35
35
|
/** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
|
|
36
36
|
export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
|
|
37
37
|
|
|
38
|
-
/** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
|
|
39
|
-
export function resizeCursor(edge: ResizeEdge): string {
|
|
40
|
-
return HANDLES[edge].cursor
|
|
41
|
-
}
|
|
42
|
-
|
|
43
38
|
/**
|
|
44
39
|
* Pointer-driven resizing for containers (service frames and modules) by dragging any border or
|
|
45
40
|
* corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { LodLevel } from '~/types/domain'
|
|
2
2
|
|
|
3
3
|
/** The LOD scale, shallow → deep. Index order lets callers ask "is at least". */
|
|
4
|
-
|
|
4
|
+
const LOD_ORDER: LodLevel[] = ['far', 'mid', 'close', 'steps', 'subtasks']
|
|
5
5
|
|
|
6
6
|
/** Map a raw zoom factor to a level-of-detail bucket. Shared by the main board
|
|
7
7
|
* and the drill-down focus view so both honour the same thresholds.
|
|
@@ -91,7 +91,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/** The built-in `PanelEntry`s: each spec's gating/order + its wrapped component. */
|
|
94
|
-
|
|
94
|
+
const INSPECTOR_PANEL_ENTRIES: PanelEntry<Block>[] = INSPECTOR_PANEL_SPECS.map((spec) => ({
|
|
95
95
|
id: spec.id,
|
|
96
96
|
order: spec.order,
|
|
97
97
|
when: spec.when,
|
|
@@ -144,9 +144,10 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
|
|
|
144
144
|
* The built-in windows as slot entries, derived from `RESULT_VIEW_IDS` so the slot order matches
|
|
145
145
|
* the canonical id order. Exhaustiveness is guaranteed by {@link BUILT_IN_RESULT_VIEWS}'s type.
|
|
146
146
|
*/
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
147
|
+
const RESULT_VIEW_CONTRIBUTIONS: readonly ResultViewContribution[] = RESULT_VIEW_IDS.map((id) => ({
|
|
148
|
+
id,
|
|
149
|
+
component: BUILT_IN_RESULT_VIEWS[id],
|
|
150
|
+
}))
|
|
150
151
|
|
|
151
152
|
/**
|
|
152
153
|
* The first-party result-views module: contributes every built-in window to the
|
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
BlockType,
|
|
3
|
-
CreateTaskType,
|
|
4
|
-
FrameRepoType,
|
|
5
|
-
TaskTypeFields,
|
|
6
|
-
Block,
|
|
7
|
-
} from '~/types/domain'
|
|
1
|
+
import type { CreateTaskType, FrameRepoType, TaskTypeFields, Block } from '~/types/domain'
|
|
8
2
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
9
3
|
import type { BoardWriteContext } from './context'
|
|
10
4
|
|
|
@@ -19,12 +13,6 @@ import type { BoardWriteContext } from './context'
|
|
|
19
13
|
export function createBoardMutations(ctx: BoardWriteContext) {
|
|
20
14
|
const { getBlock, upsert, api, present } = ctx
|
|
21
15
|
|
|
22
|
-
async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
|
|
23
|
-
const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
|
|
24
|
-
upsert(block)
|
|
25
|
-
return block
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
/**
|
|
29
17
|
* Import an existing GitHub repo (the App is installed + it's projected) as a
|
|
30
18
|
* service frame, with no bootstrap run. The backend links the repo to the new
|
|
@@ -164,7 +152,6 @@ export function createBoardMutations(ctx: BoardWriteContext) {
|
|
|
164
152
|
}
|
|
165
153
|
|
|
166
154
|
return {
|
|
167
|
-
addBlock,
|
|
168
155
|
addServiceFromRepo,
|
|
169
156
|
addTask,
|
|
170
157
|
addModule,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import type { AgentContextSnapshot, AgentSearchQuery } from '~/types/execution'
|
|
3
3
|
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
4
|
+
import { withFlag } from './withFlag'
|
|
4
5
|
|
|
5
6
|
/** What the two reads need from the store: the workspace binding, nothing else. */
|
|
6
7
|
export interface AgentContextSinkDeps {
|
|
@@ -10,14 +11,6 @@ export interface AgentContextSinkDeps {
|
|
|
10
11
|
fetchSearchQueries: (executionId: string) => Promise<{ searchQueries: AgentSearchQuery[] }>
|
|
11
12
|
}
|
|
12
13
|
|
|
13
|
-
/** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
|
|
14
|
-
function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boolean) {
|
|
15
|
-
const next = new Set(set.value)
|
|
16
|
-
if (on) next.add(key)
|
|
17
|
-
else next.delete(key)
|
|
18
|
-
set.value = next
|
|
19
|
-
}
|
|
20
|
-
|
|
21
14
|
/**
|
|
22
15
|
* The observability store's AGENT-CONTEXT and SEARCH-QUERY sinks, extracted as one cohesive pair:
|
|
23
16
|
* both are per-dispatch records the drill-down panel loads on open, neither is pushed live, and
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { ref } from 'vue'
|
|
2
2
|
import type { RunToolCallFailures, RunToolCallTrajectory } from '~/types/execution'
|
|
3
3
|
import { useSingleFlight } from '~/composables/useSingleFlight'
|
|
4
|
+
import { withFlag } from './withFlag'
|
|
4
5
|
|
|
5
6
|
// The observability store's TOOL-CALL sink, extracted whole because it is one concern with two
|
|
6
7
|
// reads and its own coherence rule between them.
|
|
@@ -40,14 +41,6 @@ export const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
|
|
|
40
41
|
truncated: false,
|
|
41
42
|
})
|
|
42
43
|
|
|
43
|
-
/** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
|
|
44
|
-
function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boolean) {
|
|
45
|
-
const next = new Set(set.value)
|
|
46
|
-
if (on) next.add(key)
|
|
47
|
-
else next.delete(key)
|
|
48
|
-
set.value = next
|
|
49
|
-
}
|
|
50
|
-
|
|
51
44
|
export function createToolCallSinkState(deps: ToolCallSinkDeps) {
|
|
52
45
|
/**
|
|
53
46
|
* One in-flight read per (sink, run). Both loads below fire on the panel OPENING, and the panel
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
/** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
|
|
4
|
+
export function withFlag(set: Ref<Set<string>>, key: string, on: boolean) {
|
|
5
|
+
const next = new Set(set.value)
|
|
6
|
+
if (on) next.add(key)
|
|
7
|
+
else next.delete(key)
|
|
8
|
+
set.value = next
|
|
9
|
+
}
|
package/app/utils/blockRects.ts
CHANGED
|
@@ -30,7 +30,7 @@ export type BlockMeasurements = {
|
|
|
30
30
|
rectFor: (el: Element) => DOMRect
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
const BLOCK_ID_ATTRIBUTE = 'data-block-id'
|
|
34
34
|
|
|
35
35
|
export function measureBlocks(root: ParentNode = document): BlockMeasurements {
|
|
36
36
|
let elements: Map<string, HTMLElement> | null = null
|
|
@@ -34,7 +34,7 @@ export type WakeGate = {
|
|
|
34
34
|
* instead of every frame, while a change that really did move a card is on screen well inside
|
|
35
35
|
* the window a reader would notice.
|
|
36
36
|
*/
|
|
37
|
-
|
|
37
|
+
const RENDER_WAKE_INTERVAL_MS = 250
|
|
38
38
|
|
|
39
39
|
export function createWakeGate(options: {
|
|
40
40
|
/** Raise the pulse. */
|
package/app/utils/catalog.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from '@cat-factory/contracts'
|
|
16
16
|
|
|
17
17
|
/** Simple unique id helper (fine for a client-only prototype). */
|
|
18
|
-
export function uid(prefix
|
|
18
|
+
export function uid(prefix: string): string {
|
|
19
19
|
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`
|
|
20
20
|
}
|
|
21
21
|
|
|
@@ -1203,16 +1203,3 @@ export const FORK_DECISION_META = {
|
|
|
1203
1203
|
icon: 'i-lucide-git-fork',
|
|
1204
1204
|
color: '#a78bfa',
|
|
1205
1205
|
}
|
|
1206
|
-
|
|
1207
|
-
/**
|
|
1208
|
-
* Whether a Coder step has the Follow-up companion enabled, given the pipeline's per-step
|
|
1209
|
-
* `followUps` toggle at index `i`. Enabled by default on a `coder` step (only `false`
|
|
1210
|
-
* disables it); ignored on other kinds.
|
|
1211
|
-
*/
|
|
1212
|
-
export function followUpCompanionEnabled(
|
|
1213
|
-
kind: string,
|
|
1214
|
-
followUps: (boolean | null)[] | undefined,
|
|
1215
|
-
i: number,
|
|
1216
|
-
): boolean {
|
|
1217
|
-
return kind === 'coder' && followUps?.[i] !== false
|
|
1218
|
-
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import type { DescriptorField } from '~/types/domain'
|
|
3
3
|
import {
|
|
4
|
-
defaultDescriptorValues,
|
|
5
4
|
descriptorFormRows,
|
|
6
5
|
descriptorGroupValue,
|
|
7
6
|
setDescriptorCheckbox,
|
|
@@ -14,50 +13,6 @@ const field = (over: Partial<DescriptorField> & Pick<DescriptorField, 'key'>): D
|
|
|
14
13
|
...over,
|
|
15
14
|
})
|
|
16
15
|
|
|
17
|
-
describe('defaultDescriptorValues', () => {
|
|
18
|
-
it('seeds each declared default in its own contract shape', () => {
|
|
19
|
-
// The form model is the wire shape, so a default has to arrive typed: the shared validator
|
|
20
|
-
// refuses a `'3'` where a `number` field is declared, and would refuse it at create time too.
|
|
21
|
-
expect(
|
|
22
|
-
defaultDescriptorValues([
|
|
23
|
-
field({ key: 'style', type: 'select', default: 'collection' }),
|
|
24
|
-
field({ key: 'depth', type: 'number', default: '3' }),
|
|
25
|
-
field({ key: 'gate', type: 'checkbox', default: 'true' }),
|
|
26
|
-
field({ key: 'ops', type: 'checkbox-group', defaultValues: ['create', 'list'] }),
|
|
27
|
-
field({ key: 'dir', type: 'path', default: 'docs' }),
|
|
28
|
-
]),
|
|
29
|
-
).toEqual({
|
|
30
|
-
style: 'collection',
|
|
31
|
-
depth: 3,
|
|
32
|
-
gate: true,
|
|
33
|
-
ops: ['create', 'list'],
|
|
34
|
-
dir: 'docs',
|
|
35
|
-
})
|
|
36
|
-
})
|
|
37
|
-
|
|
38
|
-
it('leaves a field with no meaningful default ABSENT rather than blank', () => {
|
|
39
|
-
// Absent is what validation reads as unset, so seeding `''`/`[]`/`false` would both freeze an
|
|
40
|
-
// empty answer and (for a required field) look filled to nothing that checks it.
|
|
41
|
-
expect(
|
|
42
|
-
defaultDescriptorValues([
|
|
43
|
-
field({ key: 'entity', type: 'text' }),
|
|
44
|
-
field({ key: 'notes', type: 'textarea', default: '' }),
|
|
45
|
-
field({ key: 'gate', type: 'checkbox' }),
|
|
46
|
-
field({ key: 'gateOff', type: 'checkbox', default: 'false' }),
|
|
47
|
-
field({ key: 'ops', type: 'checkbox-group', defaultValues: [] }),
|
|
48
|
-
field({ key: 'depth', type: 'number', default: 'not-a-number' }),
|
|
49
|
-
]),
|
|
50
|
-
).toEqual({})
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
it('copies a multi-select default, so editing the form cannot mutate the descriptor', () => {
|
|
54
|
-
const ops = field({ key: 'ops', type: 'checkbox-group', defaultValues: ['create'] })
|
|
55
|
-
const seeded = defaultDescriptorValues([ops])
|
|
56
|
-
;(seeded.ops as string[]).push('delete')
|
|
57
|
-
expect(ops.defaultValues).toEqual(['create'])
|
|
58
|
-
})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
16
|
describe('setDescriptorValue', () => {
|
|
62
17
|
it('drops a value the shared rules read as unset rather than freezing it', () => {
|
|
63
18
|
// Absent is what `validateDescriptorFields` treats as unfilled and what
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { descriptorFieldSections } from '@cat-factory/contracts'
|
|
2
2
|
import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
|
|
3
3
|
|
|
4
4
|
// Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
|
|
@@ -11,18 +11,6 @@ import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } fro
|
|
|
11
11
|
// inside the SFC, so the mutation rules a wrong answer would freeze on an entity are unit-testable
|
|
12
12
|
// without mounting a component.
|
|
13
13
|
|
|
14
|
-
/**
|
|
15
|
-
* The initial values a field list implies, for seeding a freshly opened form. A repo-detection
|
|
16
|
-
* probe's prefill and the user's own edits layer on top.
|
|
17
|
-
*
|
|
18
|
-
* The SHARED helper, not a form-side copy: the server folds the same defaults in at the creation
|
|
19
|
-
* door (`withDescriptorFieldDefaults`), so a duplicate here would be the drift that made a headless
|
|
20
|
-
* caller and this form disagree about what a descriptor's default means.
|
|
21
|
-
*/
|
|
22
|
-
export function defaultDescriptorValues(fields: readonly DescriptorField[]): DescriptorFieldValues {
|
|
23
|
-
return descriptorFieldDefaults(fields)
|
|
24
|
-
}
|
|
25
|
-
|
|
26
14
|
/**
|
|
27
15
|
* A value that must stay ABSENT from the bag rather than freeze on the entity: an unchecked
|
|
28
16
|
* (`false`) checkbox, a blank string, an empty multi-select, or a number that is not one (a
|
package/app/utils/dnd.ts
CHANGED
|
@@ -1,29 +1,5 @@
|
|
|
1
|
-
import type { BlockType } from '~/types/domain'
|
|
2
|
-
|
|
3
|
-
/** MIME-ish key used to carry palette payloads across the HTML5 DnD boundary. */
|
|
4
|
-
export const DND_MIME = 'application/agent-board'
|
|
5
|
-
|
|
6
|
-
export type DndPayload =
|
|
7
|
-
| { kind: 'block'; blockType: BlockType }
|
|
8
|
-
| { kind: 'pipeline'; pipelineId: string }
|
|
9
|
-
|
|
10
|
-
export function setDndPayload(event: DragEvent, payload: DndPayload) {
|
|
11
|
-
event.dataTransfer?.setData(DND_MIME, JSON.stringify(payload))
|
|
12
|
-
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy'
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function readDndPayload(event: DragEvent): DndPayload | null {
|
|
16
|
-
const raw = event.dataTransfer?.getData(DND_MIME)
|
|
17
|
-
if (!raw) return null
|
|
18
|
-
try {
|
|
19
|
-
return JSON.parse(raw) as DndPayload
|
|
20
|
-
} catch {
|
|
21
|
-
return null
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
1
|
/** Walk up from an event's target to find the block it landed on, if any. Works for any
|
|
26
|
-
* DOM event (
|
|
2
|
+
* DOM event (double-click, context menu, …): only `event.target` is read. */
|
|
27
3
|
export function blockIdFromEvent(event: Event): string | null {
|
|
28
4
|
const el = (event.target as HTMLElement | null)?.closest('[data-block-id]')
|
|
29
5
|
return el?.getAttribute('data-block-id') ?? null
|
package/app/utils/initiative.ts
CHANGED
|
@@ -79,7 +79,7 @@ export const INITIATIVE_ATTENTION_ICONS: Record<InitiativeAttentionKind, string>
|
|
|
79
79
|
* interview park is already owned by the planning window, behind the differently-worded "Answer
|
|
80
80
|
* planning questions".
|
|
81
81
|
*/
|
|
82
|
-
|
|
82
|
+
const INTERVIEW_GATE_RESULT_VIEW = 'initiative-planning'
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* The block's parked approval that is a PLAN REVIEW — the planner's human gate (`pl_initiative`
|
|
@@ -41,7 +41,7 @@ export type SettlingLoop = {
|
|
|
41
41
|
* unchanged frame would miss every animation. Four frames (~66ms at 60Hz) clears that gap
|
|
42
42
|
* while keeping a false wake-up cheap.
|
|
43
43
|
*/
|
|
44
|
-
|
|
44
|
+
const DEFAULT_SETTLE_FRAMES = 4
|
|
45
45
|
|
|
46
46
|
export function createSettlingLoop(options: {
|
|
47
47
|
/** Runs one frame; returns whether it changed anything the user can see. */
|
|
@@ -1,14 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import { headerDistanceSq, type Rect } from './taskExpansionRanking'
|
|
3
3
|
|
|
4
|
-
/** Rank a set of cards against a screen centre, best (would-expand) first. */
|
|
5
|
-
function rank(cards: Record<string, Rect>, cx: number, cy: number): string[] {
|
|
6
|
-
return Object.entries(cards)
|
|
7
|
-
.map(([id, rect]) => ({ id, dist: headerDistanceSq(rect, cx, cy) }))
|
|
8
|
-
.sort((a, b) => a.dist - b.dist)
|
|
9
|
-
.map((c) => c.id)
|
|
10
|
-
}
|
|
11
|
-
|
|
12
4
|
describe('headerDistanceSq', () => {
|
|
13
5
|
it('measures from the centre of the card top edge', () => {
|
|
14
6
|
const r: Rect = { left: 0, right: 100, top: 200, bottom: 600 }
|
|
@@ -25,27 +17,26 @@ describe('headerDistanceSq', () => {
|
|
|
25
17
|
})
|
|
26
18
|
})
|
|
27
19
|
|
|
28
|
-
describe('
|
|
29
|
-
//
|
|
30
|
-
// expands
|
|
31
|
-
//
|
|
20
|
+
describe('ordering by header distance', () => {
|
|
21
|
+
// `useTaskExpansion` sorts candidates by this measure, so the card with the smaller value is
|
|
22
|
+
// the one that expands. The regression from the screenshot: a tall card parked at the top of
|
|
23
|
+
// the screen expands its pipeline down past the centre, so its body covers the centre. A
|
|
24
|
+
// compact card whose header sits right at the centre must still win.
|
|
32
25
|
it('prefers the card whose header is at the centre over a tall card bleeding down from the top', () => {
|
|
33
26
|
const top: Rect = { left: 0, right: 200, top: 30, bottom: 700 } // header far up, body covers centre
|
|
34
27
|
const here: Rect = { left: 0, right: 200, top: 320, bottom: 520 } // header at the centre
|
|
35
|
-
expect(
|
|
36
|
-
// document order can't flip the winner
|
|
37
|
-
expect(rank({ here, top }, 100, 340)).toEqual(['here', 'top'])
|
|
28
|
+
expect(headerDistanceSq(here, 100, 340)).toBeLessThan(headerDistanceSq(top, 100, 340))
|
|
38
29
|
})
|
|
39
30
|
|
|
40
31
|
it('ranks by the header nearest the centre regardless of expansion state', () => {
|
|
41
32
|
const above: Rect = { left: 0, right: 200, top: 100, bottom: 800 }
|
|
42
33
|
const below: Rect = { left: 0, right: 200, top: 360, bottom: 420 }
|
|
43
|
-
expect(
|
|
34
|
+
expect(headerDistanceSq(below, 100, 320)).toBeLessThan(headerDistanceSq(above, 100, 320))
|
|
44
35
|
})
|
|
45
36
|
|
|
46
37
|
it('uses horizontal offset to break a vertical tie', () => {
|
|
47
38
|
const near: Rect = { left: 0, right: 100, top: 100, bottom: 200 }
|
|
48
39
|
const far: Rect = { left: 400, right: 500, top: 100, bottom: 200 }
|
|
49
|
-
expect(
|
|
40
|
+
expect(headerDistanceSq(near, 80, 100)).toBeLessThan(headerDistanceSq(far, 80, 100))
|
|
50
41
|
})
|
|
51
42
|
})
|
package/app/utils/tutorial.ts
CHANGED
|
@@ -188,7 +188,7 @@ export const TARGET_IDLE_INTERVAL_MS = 400
|
|
|
188
188
|
* two means "mostly visible" for a small control and "filling a good part of the screen" for a
|
|
189
189
|
* large one, which is the same judgement in both cases.
|
|
190
190
|
*/
|
|
191
|
-
|
|
191
|
+
const MIN_VISIBLE_RATIO = 0.5
|
|
192
192
|
|
|
193
193
|
/** Deterministic tour-list order: `order`, then `id`. */
|
|
194
194
|
export function sortTours(tours: readonly TutorialTour[]): TutorialTour[] {
|
package/app/utils/uiMode.spec.ts
CHANGED
|
@@ -27,7 +27,6 @@ describe('resolveUiMode', () => {
|
|
|
27
27
|
it('falls back to the stored choice, then to the default', () => {
|
|
28
28
|
expect(resolveUiMode(null, 'advanced')).toBe('advanced')
|
|
29
29
|
expect(resolveUiMode(null, null)).toBe(DEFAULT_UI_MODE)
|
|
30
|
-
expect(DEFAULT_UI_MODE).toBe('basic')
|
|
31
30
|
})
|
|
32
31
|
|
|
33
32
|
it('caps an intake role at basic, above BOTH the env pin and the stored choice', () => {
|
package/app/utils/uiRole.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type UiRole = (typeof UI_ROLES)[number]
|
|
|
28
28
|
* The first-run prompt is offered once per session until it is answered (see `stores/uiRole.ts`),
|
|
29
29
|
* and closing it leaves the whole product in place rather than guessing a narrower persona.
|
|
30
30
|
*/
|
|
31
|
-
|
|
31
|
+
const DEFAULT_UI_ROLE: UiRole = 'engineer'
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* How much of the SPA a role is offered.
|
package/i18n/locales/de.json
CHANGED
|
@@ -2984,13 +2984,7 @@
|
|
|
2984
2984
|
},
|
|
2985
2985
|
"canvas": {
|
|
2986
2986
|
"emptyTitle": "Ihr Board ist leer",
|
|
2987
|
-
"emptyBody": "Fügen Sie einen Service hinzu, um zu beginnen: bootstrappen Sie ein frisches Repo oder binden Sie ein bereits vorhandenes ein."
|
|
2988
|
-
"addBlockFailedTitle": "Block konnte nicht hinzugefügt werden",
|
|
2989
|
-
"addBlockFailedBody": "Das Backend hat die Anfrage abgelehnt.",
|
|
2990
|
-
"dropOntoTaskTitle": "Auf eine Aufgabe ziehen",
|
|
2991
|
-
"dropOntoTaskBody": "Pipelines laufen gegen Aufgaben, nicht gegen Services.",
|
|
2992
|
-
"taskBlockedTitle": "Aufgabe ist blockiert",
|
|
2993
|
-
"taskBlockedBody": "Ihre Abhängigkeiten wurden noch nicht gemergt."
|
|
2987
|
+
"emptyBody": "Fügen Sie einen Service hinzu, um zu beginnen: bootstrappen Sie ein frisches Repo oder binden Sie ein bereits vorhandenes ein."
|
|
2994
2988
|
},
|
|
2995
2989
|
"addTask": {
|
|
2996
2990
|
"title": "Eine Aufgabe hinzufügen",
|
package/i18n/locales/en.json
CHANGED
|
@@ -235,13 +235,7 @@
|
|
|
235
235
|
},
|
|
236
236
|
"canvas": {
|
|
237
237
|
"emptyTitle": "Your board is empty",
|
|
238
|
-
"emptyBody": "Add a service to get started: bootstrap a fresh repo or pull in one you already have."
|
|
239
|
-
"addBlockFailedTitle": "Could not add block",
|
|
240
|
-
"addBlockFailedBody": "The backend rejected the request.",
|
|
241
|
-
"dropOntoTaskTitle": "Drop onto a task",
|
|
242
|
-
"dropOntoTaskBody": "Pipelines run against tasks, not services.",
|
|
243
|
-
"taskBlockedTitle": "Task is blocked",
|
|
244
|
-
"taskBlockedBody": "Its dependencies haven't merged yet."
|
|
238
|
+
"emptyBody": "Add a service to get started: bootstrap a fresh repo or pull in one you already have."
|
|
245
239
|
},
|
|
246
240
|
"addTask": {
|
|
247
241
|
"title": "Add a task",
|
package/i18n/locales/es.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "Tu tablero está vacío",
|
|
205
|
-
"emptyBody": "Añade un servicio para empezar: arranca un repo nuevo o trae uno que ya tengas."
|
|
206
|
-
"addBlockFailedTitle": "No se pudo añadir el bloque",
|
|
207
|
-
"addBlockFailedBody": "El backend rechazó la solicitud.",
|
|
208
|
-
"dropOntoTaskTitle": "Suéltalo sobre una tarea",
|
|
209
|
-
"dropOntoTaskBody": "Las pipelines se ejecutan sobre tareas, no sobre servicios.",
|
|
210
|
-
"taskBlockedTitle": "La tarea está bloqueada",
|
|
211
|
-
"taskBlockedBody": "Sus dependencias aún no se han fusionado."
|
|
205
|
+
"emptyBody": "Añade un servicio para empezar: arranca un repo nuevo o trae uno que ya tengas."
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "Añadir una tarea",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "Votre tableau est vide",
|
|
205
|
-
"emptyBody": "Ajoutez un service pour commencer : initialisez un nouveau dépôt ou importez-en un existant."
|
|
206
|
-
"addBlockFailedTitle": "Impossible d’ajouter le bloc",
|
|
207
|
-
"addBlockFailedBody": "Le backend a rejeté la requête.",
|
|
208
|
-
"dropOntoTaskTitle": "Déposez sur une tâche",
|
|
209
|
-
"dropOntoTaskBody": "Les pipelines s’exécutent sur des tâches, pas sur des services.",
|
|
210
|
-
"taskBlockedTitle": "La tâche est bloquée",
|
|
211
|
-
"taskBlockedBody": "Ses dépendances n’ont pas encore été fusionnées."
|
|
205
|
+
"emptyBody": "Ajoutez un service pour commencer : initialisez un nouveau dépôt ou importez-en un existant."
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "Ajouter une tâche",
|
package/i18n/locales/he.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "הלוח שלך ריק",
|
|
205
|
-
"emptyBody": "הוסף שירות כדי להתחיל: אתחל מאגר חדש או משוך מאגר קיים שכבר יש לך."
|
|
206
|
-
"addBlockFailedTitle": "לא ניתן היה להוסיף בלוק",
|
|
207
|
-
"addBlockFailedBody": "השרת דחה את הבקשה.",
|
|
208
|
-
"dropOntoTaskTitle": "גרור על משימה",
|
|
209
|
-
"dropOntoTaskBody": "צינורות פועלים על משימות, לא על שירותים.",
|
|
210
|
-
"taskBlockedTitle": "המשימה חסומה",
|
|
211
|
-
"taskBlockedBody": "התלויות שלה עדיין לא מוזגו."
|
|
205
|
+
"emptyBody": "הוסף שירות כדי להתחיל: אתחל מאגר חדש או משוך מאגר קיים שכבר יש לך."
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "הוסף משימה",
|
package/i18n/locales/it.json
CHANGED
|
@@ -2984,13 +2984,7 @@
|
|
|
2984
2984
|
},
|
|
2985
2985
|
"canvas": {
|
|
2986
2986
|
"emptyTitle": "La tua board è vuota",
|
|
2987
|
-
"emptyBody": "Aggiungi un servizio per iniziare: esegui il bootstrap di un repo nuovo o importane uno che hai già."
|
|
2988
|
-
"addBlockFailedTitle": "Impossibile aggiungere il blocco",
|
|
2989
|
-
"addBlockFailedBody": "Il backend ha rifiutato la richiesta.",
|
|
2990
|
-
"dropOntoTaskTitle": "Rilascia su un'attività",
|
|
2991
|
-
"dropOntoTaskBody": "Le pipeline vengono eseguite sulle attività, non sui servizi.",
|
|
2992
|
-
"taskBlockedTitle": "L'attività è bloccata",
|
|
2993
|
-
"taskBlockedBody": "Le sue dipendenze non sono ancora state unite."
|
|
2987
|
+
"emptyBody": "Aggiungi un servizio per iniziare: esegui il bootstrap di un repo nuovo o importane uno che hai già."
|
|
2994
2988
|
},
|
|
2995
2989
|
"addTask": {
|
|
2996
2990
|
"title": "Aggiungi un'attività",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "ボードが空です",
|
|
205
|
-
"emptyBody": "サービスを追加して始めましょう。新しいリポジトリをブートストラップするか、既存のリポジトリを取り込みます。"
|
|
206
|
-
"addBlockFailedTitle": "ブロックを追加できませんでした",
|
|
207
|
-
"addBlockFailedBody": "バックエンドがリクエストを拒否しました。",
|
|
208
|
-
"dropOntoTaskTitle": "タスクにドロップ",
|
|
209
|
-
"dropOntoTaskBody": "パイプラインはサービスではなくタスクに対して実行されます。",
|
|
210
|
-
"taskBlockedTitle": "タスクがブロックされています",
|
|
211
|
-
"taskBlockedBody": "依存関係がまだマージされていません。"
|
|
205
|
+
"emptyBody": "サービスを追加して始めましょう。新しいリポジトリをブートストラップするか、既存のリポジトリを取り込みます。"
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "タスクを追加",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "Twoja tablica jest pusta",
|
|
205
|
-
"emptyBody": "Dodaj usługę, aby zacząć: zainicjuj nowe repozytorium albo dołącz istniejące."
|
|
206
|
-
"addBlockFailedTitle": "Nie udało się dodać bloku",
|
|
207
|
-
"addBlockFailedBody": "Backend odrzucił żądanie.",
|
|
208
|
-
"dropOntoTaskTitle": "Upuść na zadanie",
|
|
209
|
-
"dropOntoTaskBody": "Pipeline’y działają na zadaniach, a nie na usługach.",
|
|
210
|
-
"taskBlockedTitle": "Zadanie jest zablokowane",
|
|
211
|
-
"taskBlockedBody": "Jego zależności nie zostały jeszcze scalone."
|
|
205
|
+
"emptyBody": "Dodaj usługę, aby zacząć: zainicjuj nowe repozytorium albo dołącz istniejące."
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "Dodaj zadanie",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "Panonuz boş",
|
|
205
|
-
"emptyBody": "Başlamak için bir servis ekleyin: yeni bir depo başlatın veya zaten sahip olduğunuz bir depoyu ekleyin."
|
|
206
|
-
"addBlockFailedTitle": "Blok eklenemedi",
|
|
207
|
-
"addBlockFailedBody": "Arka uç isteği reddetti.",
|
|
208
|
-
"dropOntoTaskTitle": "Bir göreve bırakın",
|
|
209
|
-
"dropOntoTaskBody": "İşlem hatları servislere değil, görevlere karşı çalışır.",
|
|
210
|
-
"taskBlockedTitle": "Görev engellendi",
|
|
211
|
-
"taskBlockedBody": "Bağımlılıkları henüz birleştirilmedi."
|
|
205
|
+
"emptyBody": "Başlamak için bir servis ekleyin: yeni bir depo başlatın veya zaten sahip olduğunuz bir depoyu ekleyin."
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "Görev ekle",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -202,13 +202,7 @@
|
|
|
202
202
|
},
|
|
203
203
|
"canvas": {
|
|
204
204
|
"emptyTitle": "Ваша дошка порожня",
|
|
205
|
-
"emptyBody": "Додайте сервіс, щоб почати: ініціалізуйте нове репозиторій або підключіть наявне."
|
|
206
|
-
"addBlockFailedTitle": "Не вдалося додати блок",
|
|
207
|
-
"addBlockFailedBody": "Бекенд відхилив запит.",
|
|
208
|
-
"dropOntoTaskTitle": "Перетягніть на завдання",
|
|
209
|
-
"dropOntoTaskBody": "Конвеєри виконуються над завданнями, а не над сервісами.",
|
|
210
|
-
"taskBlockedTitle": "Завдання заблоковано",
|
|
211
|
-
"taskBlockedBody": "Його залежності ще не злито."
|
|
205
|
+
"emptyBody": "Додайте сервіс, щоб почати: ініціалізуйте нове репозиторій або підключіть наявне."
|
|
212
206
|
},
|
|
213
207
|
"addTask": {
|
|
214
208
|
"title": "Додати завдання",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.296.
|
|
3
|
+
"version": "0.296.3",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@cat-factory/contracts": "0.346.
|
|
21
|
+
"@cat-factory/contracts": "0.346.2",
|
|
22
22
|
"@modular-frontend/core": "0.6.0",
|
|
23
23
|
"@modular-vue/core": "^1.5.0",
|
|
24
24
|
"@modular-vue/journeys": "^1.4.0",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|
|
47
|
-
"happy-dom": "^20.
|
|
47
|
+
"happy-dom": "^20.13.2",
|
|
48
48
|
"msw": "^2.15.0",
|
|
49
49
|
"nuxt": "^4.5.2",
|
|
50
50
|
"typescript": "^6.0.3",
|