@cat-factory/app 0.184.0 → 0.186.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/README.md +4 -1
- package/app/assets/css/main.css +2 -0
- package/app/assets/css/prose.css +135 -0
- package/app/components/board/AddTaskModal.vue +31 -1
- package/app/components/brainstorm/BrainstormWindow.vue +4 -37
- package/app/components/clarity/ClarityReviewWindow.vue +4 -37
- package/app/components/initiative/InitiativePlanReview.vue +351 -0
- package/app/components/initiative/InitiativeTrackerWindow.vue +12 -126
- package/app/components/panels/AgentStepDetail.vue +19 -122
- package/app/components/panels/inspector/ServiceValidationConfig.vue +42 -4
- package/app/components/panels/inspector/TaskReviewTarget.vue +69 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +4 -37
- package/app/composables/useProseComments.ts +126 -0
- package/app/composables/useStepApproval.ts +29 -85
- package/app/composables/useStepProse.spec.ts +67 -0
- package/app/composables/useStepProse.ts +22 -10
- package/app/modular/nav-contributions.spec.ts +3 -13
- package/app/modular/panels/inspector.logic.spec.ts +7 -0
- package/app/modular/panels/inspector.logic.ts +5 -0
- package/app/modular/panels/inspector.ts +2 -0
- package/app/stores/execution.ts +9 -0
- package/app/stores/validationChecks.ts +8 -4
- package/app/utils/initiative.spec.ts +24 -0
- package/i18n/locales/de.json +17 -3
- package/i18n/locales/en.json +18 -3
- package/i18n/locales/es.json +17 -3
- package/i18n/locales/fr.json +17 -3
- package/i18n/locales/he.json +17 -3
- package/i18n/locales/it.json +17 -3
- package/i18n/locales/ja.json +17 -3
- package/i18n/locales/pl.json +17 -3
- package/i18n/locales/tr.json +17 -3
- package/i18n/locales/uk.json +17 -3
- package/package.json +2 -2
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
2
|
+
import { sliceSource } from '~/utils/agentOutput'
|
|
3
|
+
|
|
4
|
+
/** A draft per-block review comment, anchored to a source line range of the reviewed markdown. */
|
|
5
|
+
export interface ProseComment {
|
|
6
|
+
srcStart: number
|
|
7
|
+
srcEnd: number
|
|
8
|
+
quotedSource: string
|
|
9
|
+
body: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* GitHub-style per-block commenting over a rendered markdown document — the anchoring half of a
|
|
14
|
+
* prose review, with no opinion about WHAT is being reviewed or how the review is submitted.
|
|
15
|
+
*
|
|
16
|
+
* Extracted from `useStepApproval` (which now builds on it) so a second surface can offer the
|
|
17
|
+
* same affordance: the initiative tracker's plan-approval rail reviews the planner's drafted plan,
|
|
18
|
+
* which the engine renders as markdown onto the gate's proposal. Both therefore anchor comments
|
|
19
|
+
* identically — a comment quotes the exact source lines of the block it targets, so a "request
|
|
20
|
+
* changes" re-run can hand the agent back its OWN text rather than a re-rendered approximation.
|
|
21
|
+
*
|
|
22
|
+
* The anchors are the `data-src-start`/`data-src-end` attributes `parseOutputOutline` stamps on
|
|
23
|
+
* every top-level block, so this works over any document that reader renders.
|
|
24
|
+
*
|
|
25
|
+
* @param output the raw markdown being reviewed (comments quote out of it by line range)
|
|
26
|
+
* @param root the element containing the rendered blocks, for the highlight sync
|
|
27
|
+
* @param enabled whether clicking a block should start a comment (review mode is on)
|
|
28
|
+
*/
|
|
29
|
+
export function useProseComments(opts: {
|
|
30
|
+
output: () => string
|
|
31
|
+
root: () => HTMLElement | null
|
|
32
|
+
enabled: () => boolean
|
|
33
|
+
}) {
|
|
34
|
+
const comments = ref<ProseComment[]>([])
|
|
35
|
+
const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
|
|
36
|
+
const draftBody = ref('')
|
|
37
|
+
|
|
38
|
+
const blockKey = (c: { srcStart: number; srcEnd: number }) => `${c.srcStart}:${c.srcEnd}`
|
|
39
|
+
|
|
40
|
+
/** Toggle the highlight classes on commented / selected blocks within the rendered document. */
|
|
41
|
+
function syncHighlights() {
|
|
42
|
+
const root = opts.root()
|
|
43
|
+
if (!root) return
|
|
44
|
+
const commented = new Set(comments.value.map(blockKey))
|
|
45
|
+
const selected = draftTarget.value ? blockKey(draftTarget.value) : null
|
|
46
|
+
for (const el of Array.from(root.querySelectorAll('[data-src-start]'))) {
|
|
47
|
+
const key = `${el.getAttribute('data-src-start')}:${el.getAttribute('data-src-end')}`
|
|
48
|
+
el.classList.toggle('cf-commented', commented.has(key))
|
|
49
|
+
el.classList.toggle('cf-selected', key === selected)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Click a rendered block to start commenting on it (links keep working). */
|
|
54
|
+
function onProseClick(e: MouseEvent) {
|
|
55
|
+
if (!opts.enabled()) return
|
|
56
|
+
const target = e.target as HTMLElement
|
|
57
|
+
if (target.closest('a')) return
|
|
58
|
+
const blockEl = target.closest('[data-src-start]') as HTMLElement | null
|
|
59
|
+
if (!blockEl) return
|
|
60
|
+
const srcStart = Number(blockEl.getAttribute('data-src-start'))
|
|
61
|
+
const srcEnd = Number(blockEl.getAttribute('data-src-end'))
|
|
62
|
+
if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
|
|
63
|
+
draftTarget.value = {
|
|
64
|
+
srcStart,
|
|
65
|
+
srcEnd,
|
|
66
|
+
quotedSource: sliceSource(opts.output(), srcStart, srcEnd),
|
|
67
|
+
}
|
|
68
|
+
draftBody.value = ''
|
|
69
|
+
void nextTick(syncHighlights)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function addDraftComment() {
|
|
73
|
+
if (!draftTarget.value || !draftBody.value.trim()) return
|
|
74
|
+
comments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
|
|
75
|
+
draftTarget.value = null
|
|
76
|
+
draftBody.value = ''
|
|
77
|
+
void nextTick(syncHighlights)
|
|
78
|
+
}
|
|
79
|
+
function cancelDraft() {
|
|
80
|
+
draftTarget.value = null
|
|
81
|
+
draftBody.value = ''
|
|
82
|
+
void nextTick(syncHighlights)
|
|
83
|
+
}
|
|
84
|
+
function removeComment(idx: number) {
|
|
85
|
+
comments.value.splice(idx, 1)
|
|
86
|
+
void nextTick(syncHighlights)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Drop every draft — a different document (or a different subject) is being reviewed. */
|
|
90
|
+
function reset() {
|
|
91
|
+
comments.value = []
|
|
92
|
+
draftTarget.value = null
|
|
93
|
+
draftBody.value = ''
|
|
94
|
+
void nextTick(syncHighlights)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The wire shape `requestStepChanges` takes, or undefined when nothing was anchored. */
|
|
98
|
+
const wireComments = computed(() =>
|
|
99
|
+
comments.value.length
|
|
100
|
+
? comments.value.map((c) => ({
|
|
101
|
+
quotedSource: c.quotedSource,
|
|
102
|
+
srcStart: c.srcStart,
|
|
103
|
+
srcEnd: c.srcEnd,
|
|
104
|
+
body: c.body,
|
|
105
|
+
}))
|
|
106
|
+
: undefined,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Keep the in-document highlights in sync as the document renders or the drafts change.
|
|
110
|
+
watch([opts.enabled, opts.output, comments, draftTarget], () => void nextTick(syncHighlights), {
|
|
111
|
+
deep: true,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
comments,
|
|
116
|
+
wireComments,
|
|
117
|
+
draftTarget,
|
|
118
|
+
draftBody,
|
|
119
|
+
syncHighlights,
|
|
120
|
+
onProseClick,
|
|
121
|
+
addDraftComment,
|
|
122
|
+
cancelDraft,
|
|
123
|
+
removeComment,
|
|
124
|
+
reset,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { ref, computed, nextTick
|
|
1
|
+
import { ref, computed, nextTick } from 'vue'
|
|
2
2
|
import type { PipelineStep } from '~/types/execution'
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
/** A draft per-block review comment, anchored to a source range of the output. */
|
|
6
|
-
interface DraftComment {
|
|
7
|
-
srcStart: number
|
|
8
|
-
srcEnd: number
|
|
9
|
-
quotedSource: string
|
|
10
|
-
body: string
|
|
11
|
-
}
|
|
3
|
+
import { useProseComments } from '~/composables/useProseComments'
|
|
12
4
|
|
|
13
5
|
/**
|
|
14
6
|
* The GitHub-style approval/review state machine for a pending gate step. When the
|
|
15
7
|
* step's gate is pending the prose reader doubles as a review surface: the human can
|
|
16
8
|
* comment on individual source-mapped blocks, leave overall feedback, edit the
|
|
17
9
|
* conclusions in place, then Approve / Request changes / Reject. This composable owns
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
10
|
+
* the approval-API half plus the edit/reject sub-states; the per-block comment drafts and
|
|
11
|
+
* their in-document highlighting live in {@link useProseComments}, which the initiative
|
|
12
|
+
* tracker's plan-approval rail shares. The parent supplies the live step, the scroll
|
|
13
|
+
* container (for highlight lookups), the run/approval ids, and a `close` callback the
|
|
14
|
+
* actions invoke once they resolve.
|
|
21
15
|
*/
|
|
22
16
|
export function useStepApproval(opts: {
|
|
23
17
|
step: () => PipelineStep | null
|
|
@@ -30,11 +24,8 @@ export function useStepApproval(opts: {
|
|
|
30
24
|
}) {
|
|
31
25
|
const execution = useExecutionStore()
|
|
32
26
|
|
|
33
|
-
const reviewComments = ref<DraftComment[]>([])
|
|
34
27
|
const feedback = ref('')
|
|
35
28
|
const submitting = ref(false)
|
|
36
|
-
const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
|
|
37
|
-
const draftBody = ref('')
|
|
38
29
|
|
|
39
30
|
// "Approve with corrections" mode: a deliberate state distinct from the read-only
|
|
40
31
|
// review — the human edits the conclusions directly and those edits flow forward as
|
|
@@ -45,56 +36,25 @@ export function useStepApproval(opts: {
|
|
|
45
36
|
// Reject stops the whole run, so it's a two-step inline confirm (no native dialog).
|
|
46
37
|
const rejectArmed = ref(false)
|
|
47
38
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (target.closest('a')) return
|
|
68
|
-
const blockEl = target.closest('[data-src-start]') as HTMLElement | null
|
|
69
|
-
if (!blockEl) return
|
|
70
|
-
const srcStart = Number(blockEl.getAttribute('data-src-start'))
|
|
71
|
-
const srcEnd = Number(blockEl.getAttribute('data-src-end'))
|
|
72
|
-
if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
|
|
73
|
-
draftTarget.value = {
|
|
74
|
-
srcStart,
|
|
75
|
-
srcEnd,
|
|
76
|
-
quotedSource: sliceSource(opts.step()?.output ?? '', srcStart, srcEnd),
|
|
77
|
-
}
|
|
78
|
-
draftBody.value = ''
|
|
79
|
-
void nextTick(syncHighlights)
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function addDraftComment() {
|
|
83
|
-
if (!draftTarget.value || !draftBody.value.trim()) return
|
|
84
|
-
reviewComments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
|
|
85
|
-
draftTarget.value = null
|
|
86
|
-
draftBody.value = ''
|
|
87
|
-
void nextTick(syncHighlights)
|
|
88
|
-
}
|
|
89
|
-
function cancelDraft() {
|
|
90
|
-
draftTarget.value = null
|
|
91
|
-
draftBody.value = ''
|
|
92
|
-
void nextTick(syncHighlights)
|
|
93
|
-
}
|
|
94
|
-
function removeComment(idx: number) {
|
|
95
|
-
reviewComments.value.splice(idx, 1)
|
|
96
|
-
void nextTick(syncHighlights)
|
|
97
|
-
}
|
|
39
|
+
// The per-block comment drafts + their in-document highlights. Commenting is off while the
|
|
40
|
+
// human is in edit mode: the two paths are mutually exclusive, and the editor replaces the
|
|
41
|
+
// rendered document with a textarea, so there are no blocks to anchor to.
|
|
42
|
+
const prose = useProseComments({
|
|
43
|
+
output: () => opts.step()?.output ?? '',
|
|
44
|
+
root: () => opts.scrollEl(),
|
|
45
|
+
enabled: () => opts.approvalPending() && !opts.companionExceeded() && !editing.value,
|
|
46
|
+
})
|
|
47
|
+
const {
|
|
48
|
+
comments: reviewComments,
|
|
49
|
+
wireComments,
|
|
50
|
+
draftTarget,
|
|
51
|
+
draftBody,
|
|
52
|
+
syncHighlights,
|
|
53
|
+
onProseClick,
|
|
54
|
+
addDraftComment,
|
|
55
|
+
cancelDraft,
|
|
56
|
+
removeComment,
|
|
57
|
+
} = prose
|
|
98
58
|
|
|
99
59
|
const canRequestChanges = computed(
|
|
100
60
|
() => !!feedback.value.trim() || reviewComments.value.length > 0,
|
|
@@ -119,7 +79,7 @@ export function useStepApproval(opts: {
|
|
|
119
79
|
editing.value = true
|
|
120
80
|
// Editing and the review/reject path are mutually exclusive — clear the other.
|
|
121
81
|
rejectArmed.value = false
|
|
122
|
-
|
|
82
|
+
prose.cancelDraft()
|
|
123
83
|
void nextTick(syncHighlights)
|
|
124
84
|
}
|
|
125
85
|
function cancelEditing() {
|
|
@@ -143,14 +103,7 @@ export function useStepApproval(opts: {
|
|
|
143
103
|
try {
|
|
144
104
|
const ok = await execution.requestStepChanges(opts.instanceId()!, id, {
|
|
145
105
|
feedback: feedback.value.trim() || undefined,
|
|
146
|
-
comments:
|
|
147
|
-
? reviewComments.value.map((c) => ({
|
|
148
|
-
quotedSource: c.quotedSource,
|
|
149
|
-
srcStart: c.srcStart,
|
|
150
|
-
srcEnd: c.srcEnd,
|
|
151
|
-
body: c.body,
|
|
152
|
-
}))
|
|
153
|
-
: undefined,
|
|
106
|
+
comments: wireComments.value,
|
|
154
107
|
})
|
|
155
108
|
if (ok) opts.close()
|
|
156
109
|
} finally {
|
|
@@ -189,22 +142,13 @@ export function useStepApproval(opts: {
|
|
|
189
142
|
|
|
190
143
|
/** Full reset of every draft when a different gate/step opens. */
|
|
191
144
|
function resetForStep() {
|
|
192
|
-
|
|
145
|
+
prose.reset()
|
|
193
146
|
feedback.value = ''
|
|
194
|
-
draftTarget.value = null
|
|
195
|
-
draftBody.value = ''
|
|
196
147
|
rejectArmed.value = false
|
|
197
148
|
editing.value = false
|
|
198
149
|
draftProposal.value = ''
|
|
199
150
|
}
|
|
200
151
|
|
|
201
|
-
// Keep the in-document highlights in sync as the output renders or comments change.
|
|
202
|
-
watch(
|
|
203
|
-
[opts.approvalPending, () => opts.step()?.output, reviewComments, draftTarget],
|
|
204
|
-
() => void nextTick(syncHighlights),
|
|
205
|
-
{ deep: true },
|
|
206
|
-
)
|
|
207
|
-
|
|
208
152
|
return {
|
|
209
153
|
reviewComments,
|
|
210
154
|
feedback,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { useStepProse } from './useStepProse'
|
|
3
|
+
|
|
4
|
+
const DOC = ['# Plan', '', 'Intro.', '', '## Phase 1', '', 'First.', '', '## Phase 2', ''].join(
|
|
5
|
+
'\n',
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A fake measurable element: the scroll-spy only ever reads `getBoundingClientRect().top`, so
|
|
10
|
+
* the vertical position is the whole of what a section is to it.
|
|
11
|
+
*/
|
|
12
|
+
function sectionAt(top: number): HTMLElement {
|
|
13
|
+
return { getBoundingClientRect: () => ({ top }) } as unknown as HTMLElement
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('useStepProse scroll-spy', () => {
|
|
17
|
+
/**
|
|
18
|
+
* The reader's own layout: a details card ahead of the prose, which the consumer registers.
|
|
19
|
+
*/
|
|
20
|
+
it('tracks the last anchor above the fold, lead section included', () => {
|
|
21
|
+
const prose = useStepProse(() => DOC)
|
|
22
|
+
prose.scrollEl.value = sectionAt(0)
|
|
23
|
+
const [first, second] = prose.tocSections.value
|
|
24
|
+
prose.sectionEls['step-details'] = sectionAt(-200)
|
|
25
|
+
prose.sectionEls[first!.id] = sectionAt(-100)
|
|
26
|
+
prose.sectionEls[second!.id] = sectionAt(400)
|
|
27
|
+
|
|
28
|
+
prose.onScroll()
|
|
29
|
+
expect(prose.activeId.value).toBe(first!.id)
|
|
30
|
+
|
|
31
|
+
// Scrolling on past the second heading moves the highlight to it.
|
|
32
|
+
prose.sectionEls[second!.id] = sectionAt(20)
|
|
33
|
+
prose.onScroll()
|
|
34
|
+
expect(prose.activeId.value).toBe(second!.id)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The regression this option exists for. A consumer that renders the document ALONE (the
|
|
39
|
+
* initiative tracker's plan-approval rail) never registers a lead anchor, and the spy walks
|
|
40
|
+
* anchors in document order and stops at the first one it cannot measure — so with the lead
|
|
41
|
+
* anchor hardcoded it stopped immediately, pinning `activeId` to a section that does not
|
|
42
|
+
* exist. Nothing threw; the ToC simply never highlighted anything, and any click-to-navigate
|
|
43
|
+
* highlight was wiped by the scroll event the smooth scroll itself fires.
|
|
44
|
+
*/
|
|
45
|
+
it('tracks sections when the consumer renders no lead anchor', () => {
|
|
46
|
+
const prose = useStepProse(() => DOC, { leadAnchorId: null })
|
|
47
|
+
prose.scrollEl.value = sectionAt(0)
|
|
48
|
+
const [first, second] = prose.tocSections.value
|
|
49
|
+
prose.sectionEls[first!.id] = sectionAt(-100)
|
|
50
|
+
prose.sectionEls[second!.id] = sectionAt(400)
|
|
51
|
+
|
|
52
|
+
prose.onScroll()
|
|
53
|
+
expect(prose.activeId.value).toBe(first!.id)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('starts on the lead anchor, or on nothing when there is none', () => {
|
|
57
|
+
expect(useStepProse(() => DOC).activeId.value).toBe('step-details')
|
|
58
|
+
expect(useStepProse(() => DOC, { leadAnchorId: null }).activeId.value).toBe('')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('re-seeds the active anchor on reset', () => {
|
|
62
|
+
const prose = useStepProse(() => DOC, { leadAnchorId: null })
|
|
63
|
+
prose.activeId.value = 'somewhere'
|
|
64
|
+
prose.reset()
|
|
65
|
+
expect(prose.activeId.value).toBe('')
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -4,23 +4,35 @@ import { parseOutputOutline } from '~/utils/agentOutput'
|
|
|
4
4
|
/**
|
|
5
5
|
* The prose reader for an agent step's markdown output: its heading outline, the
|
|
6
6
|
* per-section collapse state, and the scroll-spy that keeps the ToC in sync.
|
|
7
|
-
* Owns the scroll container + per-section element refs the template binds
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Owns the scroll container + per-section element refs the template binds.
|
|
8
|
+
* `reset()` re-seeds (all sections expanded, scrolled to top) whenever a different
|
|
9
|
+
* step opens.
|
|
10
|
+
*
|
|
11
|
+
* `leadAnchorId` names a section the CONSUMER renders ahead of the prose and registers in
|
|
12
|
+
* `sectionEls` — the step reader's details card. It is an option rather than a constant
|
|
13
|
+
* because the scroll-spy walks the anchors in document order and stops at the first one it
|
|
14
|
+
* cannot measure: a consumer that renders no such element (the initiative tracker's
|
|
15
|
+
* plan-approval rail, which has the document alone) would otherwise have its spy stop dead on
|
|
16
|
+
* an anchor that never exists, pinning `activeId` to a section nobody can see and leaving the
|
|
17
|
+
* ToC with nothing highlighted. Pass `null` when the prose is the whole document.
|
|
10
18
|
*/
|
|
11
|
-
export function useStepProse(getOutput: () => string) {
|
|
19
|
+
export function useStepProse(getOutput: () => string, opts: { leadAnchorId?: string | null } = {}) {
|
|
20
|
+
const leadAnchorId = opts.leadAnchorId === undefined ? 'step-details' : opts.leadAnchorId
|
|
12
21
|
const outline = computed(() => parseOutputOutline(getOutput()))
|
|
13
22
|
const tocSections = computed(() => outline.value.sections.filter((s) => s.depth > 0))
|
|
14
23
|
const hasOutput = computed(() => !!getOutput().trim())
|
|
15
24
|
|
|
16
25
|
const collapsed = reactive<Record<string, boolean>>({})
|
|
17
|
-
const activeId = ref<string>('
|
|
26
|
+
const activeId = ref<string>(leadAnchorId ?? '')
|
|
18
27
|
const scrollEl = ref<HTMLElement | null>(null)
|
|
19
28
|
const sectionEls = reactive<Record<string, HTMLElement | null>>({})
|
|
20
29
|
|
|
21
|
-
// Anchors the ToC navigates + the scroll-spy tracks: the
|
|
22
|
-
// every heading section of the prose.
|
|
23
|
-
const anchors = computed(() => [
|
|
30
|
+
// Anchors the ToC navigates + the scroll-spy tracks: the lead section (when the consumer
|
|
31
|
+
// renders one) first, then every heading section of the prose.
|
|
32
|
+
const anchors = computed(() => [
|
|
33
|
+
...(leadAnchorId ? [leadAnchorId] : []),
|
|
34
|
+
...tocSections.value.map((s) => s.id),
|
|
35
|
+
])
|
|
24
36
|
|
|
25
37
|
function toggle(id: string) {
|
|
26
38
|
collapsed[id] = !collapsed[id]
|
|
@@ -43,7 +55,7 @@ export function useStepProse(getOutput: () => string) {
|
|
|
43
55
|
const container = scrollEl.value
|
|
44
56
|
if (!container) return
|
|
45
57
|
const line = container.getBoundingClientRect().top + 80
|
|
46
|
-
let current = anchors.value[0] ?? '
|
|
58
|
+
let current = anchors.value[0] ?? ''
|
|
47
59
|
for (const id of anchors.value) {
|
|
48
60
|
const el = sectionEls[id]
|
|
49
61
|
if (el && el.getBoundingClientRect().top <= line) current = id
|
|
@@ -55,7 +67,7 @@ export function useStepProse(getOutput: () => string) {
|
|
|
55
67
|
// Re-seed (all sections expanded, scrolled to top) for a freshly-opened step.
|
|
56
68
|
function reset() {
|
|
57
69
|
for (const k of Object.keys(collapsed)) delete collapsed[k]
|
|
58
|
-
activeId.value = '
|
|
70
|
+
activeId.value = leadAnchorId ?? ''
|
|
59
71
|
void nextTick(() => scrollEl.value?.scrollTo({ top: 0 }))
|
|
60
72
|
}
|
|
61
73
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import
|
|
2
|
+
import { hasI18nKey } from '../../test/i18nKeys'
|
|
3
3
|
import {
|
|
4
4
|
groupCommands,
|
|
5
5
|
groupSidebar,
|
|
@@ -10,18 +10,8 @@ import {
|
|
|
10
10
|
} from './nav-contributions'
|
|
11
11
|
import type { AppSlots, NavGates } from './nav-contributions'
|
|
12
12
|
|
|
13
|
-
/**
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
/** Walk a dotted vue-i18n key path; true when it resolves to a leaf string. */
|
|
17
|
-
function hasKey(path: string): boolean {
|
|
18
|
-
let node: unknown = en
|
|
19
|
-
for (const part of path.split('.')) {
|
|
20
|
-
if (typeof node !== 'object' || node === null || !(part in node)) return false
|
|
21
|
-
node = (node as Record<string, unknown>)[part]
|
|
22
|
-
}
|
|
23
|
-
return typeof node === 'string'
|
|
24
|
-
}
|
|
13
|
+
/** Prove every referenced key resolves in the layer's base catalog (see `test/i18nKeys`). */
|
|
14
|
+
const hasKey = hasI18nKey
|
|
25
15
|
|
|
26
16
|
const NO_GATES: NavGates = {
|
|
27
17
|
canWriteBoard: false,
|
|
@@ -80,6 +80,13 @@ describe('inspector panel group', () => {
|
|
|
80
80
|
expect(visibleIds(block('module'))).toEqual(['container-summary'])
|
|
81
81
|
})
|
|
82
82
|
|
|
83
|
+
// The reviewed PR is the review task's SUBJECT, so it leads the body — above the context the
|
|
84
|
+
// task was given and the run that acts on it. Every other task type never sees the panel.
|
|
85
|
+
it('a review task leads with its review target', () => {
|
|
86
|
+
const review = { ...block('task'), taskType: 'review' } as Block
|
|
87
|
+
expect(visibleIds(review)[0]).toBe('task-review-target')
|
|
88
|
+
})
|
|
89
|
+
|
|
83
90
|
it('a task shows the task body in the pre-slice-4 order', () => {
|
|
84
91
|
expect(visibleIds(block('task'))).toEqual([
|
|
85
92
|
'task-context-docs',
|
|
@@ -36,6 +36,7 @@ export const inspectorPanels = definePanelGroup<Block>(INSPECTOR_PANELS_SLOT)
|
|
|
36
36
|
* a silently-dropped panel). */
|
|
37
37
|
export const INSPECTOR_PANEL_IDS = [
|
|
38
38
|
// task body (rendered in this order under the identity block)
|
|
39
|
+
'task-review-target',
|
|
39
40
|
'task-context-docs',
|
|
40
41
|
'task-context-issues',
|
|
41
42
|
'recurring-schedule',
|
|
@@ -119,6 +120,10 @@ const isDeployableFrame = (b: Block) => isFrame(b) && b.type !== 'document'
|
|
|
119
120
|
* extensibility value.
|
|
120
121
|
*/
|
|
121
122
|
export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
123
|
+
// FIRST in the task body: for a review task the reviewed PR is what the task IS, so it reads
|
|
124
|
+
// above the context it was given and the run that acts on it. Gated on the task TYPE alone —
|
|
125
|
+
// the panel itself hides when the task carries no PR reference.
|
|
126
|
+
{ id: 'task-review-target', order: 5, when: (b) => isTask(b) && b.taskType === 'review' },
|
|
122
127
|
// Shared with the initiative body — an initiative takes the same attachments a task does.
|
|
123
128
|
{ id: 'task-context-docs', order: 10, when: takesContext },
|
|
124
129
|
{ id: 'task-context-issues', order: 20, when: takesContext },
|
|
@@ -16,6 +16,7 @@ import TaskContextDocs from '~/components/documents/TaskContextDocs.vue'
|
|
|
16
16
|
import TaskContextIssues from '~/components/tasks/TaskContextIssues.vue'
|
|
17
17
|
import RecurringScheduleSettings from '~/components/panels/inspector/RecurringScheduleSettings.vue'
|
|
18
18
|
import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
|
|
19
|
+
import TaskReviewTarget from '~/components/panels/inspector/TaskReviewTarget.vue'
|
|
19
20
|
import TaskEstimateBadge from '~/components/panels/inspector/TaskEstimateBadge.vue'
|
|
20
21
|
import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue'
|
|
21
22
|
import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
|
|
@@ -65,6 +66,7 @@ function blockPanel(component: Component, id: InspectorPanelId): PanelComponent
|
|
|
65
66
|
/** Exhaustive id → sub-panel map. Typed `Record<InspectorPanelId, …>` so adding a
|
|
66
67
|
* spec without a component (or vice-versa) fails the typecheck. */
|
|
67
68
|
const COMPONENTS: Record<InspectorPanelId, Component> = {
|
|
69
|
+
'task-review-target': TaskReviewTarget,
|
|
68
70
|
'task-context-docs': TaskContextDocs,
|
|
69
71
|
'task-context-issues': TaskContextIssues,
|
|
70
72
|
'recurring-schedule': RecurringScheduleSettings,
|
package/app/stores/execution.ts
CHANGED
|
@@ -188,6 +188,14 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
188
188
|
blockId: string
|
|
189
189
|
approval: StepApproval
|
|
190
190
|
agentKind: PipelineStep['agentKind']
|
|
191
|
+
/**
|
|
192
|
+
* Whether the gate's proposal is a RENDERING of an artifact the step already committed
|
|
193
|
+
* (`step.outputIsRendered`). Projected here because a surface that reviews the proposal
|
|
194
|
+
* WITHOUT the step in hand — the initiative tracker's plan-approval rail — otherwise has
|
|
195
|
+
* no way to tell a rendered document from the agent's raw transcript summary, and would
|
|
196
|
+
* present a one-line summary as though it were the artifact.
|
|
197
|
+
*/
|
|
198
|
+
outputIsRendered: boolean
|
|
191
199
|
}[] = []
|
|
192
200
|
for (const e of instances.value) {
|
|
193
201
|
for (const s of e.steps) {
|
|
@@ -197,6 +205,7 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
197
205
|
blockId: e.blockId,
|
|
198
206
|
approval: s.approval,
|
|
199
207
|
agentKind: s.agentKind,
|
|
208
|
+
outputIsRendered: s.outputIsRendered === true,
|
|
200
209
|
})
|
|
201
210
|
}
|
|
202
211
|
}
|
|
@@ -86,21 +86,25 @@ export const useValidationChecksStore = defineStore('validationChecks', () => {
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
89
|
-
* Save a service frame's checks
|
|
90
|
-
*
|
|
91
|
-
* the entry
|
|
89
|
+
* Save a service frame's checks and its dependency-prepopulation install. The backend deletes
|
|
90
|
+
* the row only when BOTH are empty (restoring the exact pre-feature behaviour), so the local
|
|
91
|
+
* list mirrors that rule — dropping the entry on an empty save of both, and keeping it for a
|
|
92
|
+
* service that declares only an install. Testing `checks` alone here would evict a live
|
|
93
|
+
* install-only config from the store and report the service as unconfigured until a reload.
|
|
92
94
|
*/
|
|
93
95
|
async function save(
|
|
94
96
|
blockId: string,
|
|
95
97
|
checks: ValidationCheck[],
|
|
96
98
|
maxAttempts: number,
|
|
99
|
+
dependencyInstall?: string,
|
|
97
100
|
): Promise<void> {
|
|
98
101
|
const ws = useWorkspaceStore()
|
|
99
102
|
const saved = await api.setServiceValidationConfig(ws.requireId(), blockId, {
|
|
100
103
|
checks,
|
|
101
104
|
maxAttempts,
|
|
105
|
+
...(dependencyInstall ? { dependencyInstall } : {}),
|
|
102
106
|
})
|
|
103
|
-
if (saved.checks.length === 0) dropLocal(blockId)
|
|
107
|
+
if (saved.checks.length === 0 && !saved.dependencyInstall) dropLocal(blockId)
|
|
104
108
|
else upsertLocal(saved)
|
|
105
109
|
}
|
|
106
110
|
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { INITIATIVE_ITEM_TERMINAL_STATUSES } from '@cat-factory/contracts'
|
|
2
2
|
import { describe, it, expect } from 'vitest'
|
|
3
3
|
import type { InitiativeItem, InitiativePhase, InitiativeQa } from '~/types/domain'
|
|
4
|
+
import { missingI18nKeys } from '../../test/i18nKeys'
|
|
4
5
|
import {
|
|
6
|
+
INITIATIVE_ATTENTION_LABEL_KEYS,
|
|
7
|
+
INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
|
|
8
|
+
INITIATIVE_STATUS_LABEL_KEYS,
|
|
5
9
|
isPendingQuestion,
|
|
6
10
|
orderInterviewQuestions,
|
|
7
11
|
pendingCheckpointPhase,
|
|
@@ -200,3 +204,23 @@ describe('selectPlanApproval', () => {
|
|
|
200
204
|
expect(selectPlanApproval(approvals, resultViewOf)?.approval.id).toBe('ap_custom')
|
|
201
205
|
})
|
|
202
206
|
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* These tables are the reason the initiative card and the inspector word one park identically,
|
|
210
|
+
* and they are exactly the shape both i18n drift guards are blind to: the typed-key check and
|
|
211
|
+
* `i18n:check` only see a key written literally at a `t()` call site, while the exhaustive
|
|
212
|
+
* `Record` only proves every enum MEMBER has an entry — never that the entry still names a key
|
|
213
|
+
* the catalog holds. Without this, deleting a key reads as a clean removal and the affordance
|
|
214
|
+
* renders its own key path to the user.
|
|
215
|
+
*/
|
|
216
|
+
describe('the initiative label-key tables', () => {
|
|
217
|
+
it('name keys the base catalog actually holds', () => {
|
|
218
|
+
expect(
|
|
219
|
+
missingI18nKeys([
|
|
220
|
+
...Object.values(INITIATIVE_ATTENTION_LABEL_KEYS),
|
|
221
|
+
...Object.values(INITIATIVE_STATUS_LABEL_KEYS),
|
|
222
|
+
...Object.values(INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS),
|
|
223
|
+
]),
|
|
224
|
+
).toEqual([])
|
|
225
|
+
})
|
|
226
|
+
})
|
package/i18n/locales/de.json
CHANGED
|
@@ -1285,6 +1285,12 @@
|
|
|
1285
1285
|
"risk": "Risiko",
|
|
1286
1286
|
"impact": "Wirkung"
|
|
1287
1287
|
},
|
|
1288
|
+
"reviewTarget": {
|
|
1289
|
+
"title": "Geprüfter Pull Request",
|
|
1290
|
+
"hint": "Der bestehende Pull Request, den diese Aufgabe prüft.",
|
|
1291
|
+
"prNumber": "PR #{number}",
|
|
1292
|
+
"focus": "Schwerpunkt: {focus}"
|
|
1293
|
+
},
|
|
1288
1294
|
"execution": {
|
|
1289
1295
|
"title": "Ausführung",
|
|
1290
1296
|
"hint": "Der Live-Lauf der Pipeline: jeder Agent-Schritt, sein Fortschritt und der resultierende Pull Request.",
|
|
@@ -1433,6 +1439,8 @@
|
|
|
1433
1439
|
"validationChecks": {
|
|
1434
1440
|
"title": "Prüfungen vor dem PR",
|
|
1435
1441
|
"sectionHint": "Befehle, die nach dem Coder und vor dem Öffnen eines Pull Requests im Checkout laufen. Ein Fehlschlag geht zur Behebung an den Agenten zurück; nur ein fehlerfreier Checkout öffnet einen PR.",
|
|
1442
|
+
"dependencyInstall": "Abhängigkeiten installieren",
|
|
1443
|
+
"dependencyInstallHint": "Läuft, bevor der Agent startet",
|
|
1436
1444
|
"hint": "Jeder Befehl läuft der Reihe nach mit `sh -c` im Checkout dieses Dienstes. Der Agent soll den Code reparieren, nicht die Prüfung abschwächen.",
|
|
1437
1445
|
"clear": "Leeren",
|
|
1438
1446
|
"configNoun": "Prüfungen",
|
|
@@ -1452,6 +1460,7 @@
|
|
|
1452
1460
|
"action": "Erkennen",
|
|
1453
1461
|
"hint": "Prüfungen aus dem Repository dieses Dienstes vorschlagen",
|
|
1454
1462
|
"added": "{count} Prüfung hinzugefügt | {count} Prüfungen hinzugefügt",
|
|
1463
|
+
"installOnly": "Abhängigkeitsinstallation eingetragen",
|
|
1455
1464
|
"found": "Erkannt: {ecosystems}.",
|
|
1456
1465
|
"capped": "Einige Vorschläge wurden ausgelassen — ein Dienst nimmt höchstens {max} Prüfungen auf.",
|
|
1457
1466
|
"nothingNew": "Nichts hinzuzufügen",
|
|
@@ -1622,6 +1631,7 @@
|
|
|
1622
1631
|
"editConclusionsPlaceholder": "Bearbeite die Schlussfolgerungen des Agents; deine Änderungen werden bei der Freigabe gespeichert…",
|
|
1623
1632
|
"noProseOutput": "Dieser Agent hat keine Prosa-Ausgabe erzeugt.",
|
|
1624
1633
|
"approveWithCorrections": "Mit Korrekturen freigeben",
|
|
1634
|
+
"renderedOutputNote": "Diese Ausgabe ist eine Darstellung dessen, was der Schritt erzeugt hat, und kann hier nicht bearbeitet werden. Fordere Änderungen an, um sie überarbeiten zu lassen.",
|
|
1625
1635
|
"reviewAndApprove": "Prüfen & freigeben",
|
|
1626
1636
|
"editHint": "Bearbeite die Schlussfolgerungen links; deine Änderungen werden bei der Freigabe gespeichert.",
|
|
1627
1637
|
"reviewHint": "Klicke auf einen beliebigen Block in der Ausgabe, um ihn zu kommentieren, oder hinterlasse unten allgemeines Feedback.",
|
|
@@ -2421,7 +2431,9 @@
|
|
|
2421
2431
|
"focus": "Prüfungsschwerpunkt",
|
|
2422
2432
|
"focusPlaceholder": "z. B. Fokus auf die Auth-Änderungen und Fehlerbehandlung",
|
|
2423
2433
|
"derivedTitle": "{ref} prüfen",
|
|
2424
|
-
"derivedTitleFallback": "Pull Request prüfen"
|
|
2434
|
+
"derivedTitleFallback": "Pull Request prüfen",
|
|
2435
|
+
"prNotFound": "Pull Request #{number} wurde im Repository dieses Service nicht gefunden. Prüfe die Nummer, oder verknüpfe den Service mit dem Repository, in dem der Pull Request liegt.",
|
|
2436
|
+
"prRepoMismatch": "Dieser Pull Request liegt in einem anderen Repository. Dieser Service prüft {repo}; lege die Prüfaufgabe unter dem Service an, der mit dem Repository des Pull Requests verknüpft ist."
|
|
2425
2437
|
}
|
|
2426
2438
|
},
|
|
2427
2439
|
"recurring": {
|
|
@@ -4329,9 +4341,11 @@
|
|
|
4329
4341
|
"title": "Dieser Plan wartet auf dich",
|
|
4330
4342
|
"body": "Der Planner hat die Phasen und Aufgaben unten entworfen. Gib sie frei, um den Plan zu committen und die Arbeit zu starten, oder schicke den Plan mit deinen Änderungswünschen zurück.",
|
|
4331
4343
|
"approve": "Plan freigeben",
|
|
4332
|
-
"requestChanges": "Änderungen anfordern",
|
|
4333
4344
|
"feedbackPlaceholder": "Was soll der Planner ändern? Umfang, Reihenfolge der Phasen, fehlende Arbeit, eine Aufgabe, die woanders hingehört …",
|
|
4334
|
-
"sendBack": "An den Planner zurückschicken"
|
|
4345
|
+
"sendBack": "An den Planner zurückschicken",
|
|
4346
|
+
"noDocument": "Dieser Plan entstand, bevor die Plattform Pläne zur Prüfung gerendert hat — es gibt kein Dokument zum Navigieren; die Abschnitte unten sind der Plan.",
|
|
4347
|
+
"needsFeedback": "Füge zuerst einen Kommentar oder eine Rückmeldung hinzu — der Planer plant damit neu.",
|
|
4348
|
+
"commentHint": "Klicke auf einen Teil des Plans, um ihn zu kommentieren."
|
|
4335
4349
|
},
|
|
4336
4350
|
"planning": {
|
|
4337
4351
|
"title": "Die Initiative planen",
|