@cat-factory/app 0.185.0 → 0.187.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/fragments/FragmentLibraryManager.vue +39 -2
- 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/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/utils/initiative.spec.ts +24 -0
- package/i18n/locales/de.json +16 -3
- package/i18n/locales/en.json +17 -3
- package/i18n/locales/es.json +16 -3
- package/i18n/locales/fr.json +16 -3
- package/i18n/locales/he.json +16 -3
- package/i18n/locales/it.json +16 -3
- package/i18n/locales/ja.json +16 -3
- package/i18n/locales/pl.json +16 -3
- package/i18n/locales/tr.json +16 -3
- package/i18n/locales/uk.json +16 -3
- package/package.json +2 -2
|
@@ -148,6 +148,14 @@ const {
|
|
|
148
148
|
|
|
149
149
|
const approvalPending = computed(() => step.value?.approval?.status === 'pending')
|
|
150
150
|
const approvalId = computed(() => step.value?.approval?.id ?? null)
|
|
151
|
+
// Whether "approve with corrections" applies. A step whose output is a deterministic
|
|
152
|
+
// RENDERING of an artifact it already produced (the initiative plan, the spec doc, the
|
|
153
|
+
// blueprint tree — `step.outputIsRendered`, set by the engine's `reviewableArtifactOutput`
|
|
154
|
+
// seam) has no editable proposal: the artifact was ingested into domain state before the gate
|
|
155
|
+
// was raised, so an edit typed over the rendering would never reach it. The backend refuses
|
|
156
|
+
// such an edit; hiding the affordance is what stops a reviewer typing corrections into a
|
|
157
|
+
// dead end. Their route is "request changes", which re-runs the producer with the correction.
|
|
158
|
+
const proposalEditable = computed(() => step.value?.outputIsRendered !== true)
|
|
151
159
|
// A companion step parked at its automatic-rework cap: instead of the generic
|
|
152
160
|
// approve/request-changes/reject rail, it shows the shared iteration-cap prompt
|
|
153
161
|
// (one more round / proceed / stop & reset), resolved through its own endpoint.
|
|
@@ -261,6 +269,7 @@ async function copyOutput() {
|
|
|
261
269
|
<!-- ToC sidebar (only meaningful when there are prose headings) -->
|
|
262
270
|
<aside
|
|
263
271
|
v-if="outline.hasToc"
|
|
272
|
+
data-testid="step-detail-toc"
|
|
264
273
|
class="hidden w-72 shrink-0 flex-col border-e border-slate-800 bg-slate-900/60 md:flex"
|
|
265
274
|
>
|
|
266
275
|
<div class="border-b border-slate-800 px-4 py-3">
|
|
@@ -646,6 +655,7 @@ async function copyOutput() {
|
|
|
646
655
|
<!-- composer for the block the human just clicked -->
|
|
647
656
|
<div
|
|
648
657
|
v-if="draftTarget"
|
|
658
|
+
data-testid="step-review-composer"
|
|
649
659
|
class="rounded-lg border border-indigo-500/40 bg-indigo-500/5 p-3"
|
|
650
660
|
>
|
|
651
661
|
<div class="mb-1 text-[10px] uppercase tracking-wide text-indigo-300">
|
|
@@ -656,6 +666,7 @@ async function copyOutput() {
|
|
|
656
666
|
>{{ draftTarget.quotedSource }}</pre>
|
|
657
667
|
<UTextarea
|
|
658
668
|
v-model="draftBody"
|
|
669
|
+
data-testid="step-review-comment-body"
|
|
659
670
|
:rows="3"
|
|
660
671
|
autoresize
|
|
661
672
|
size="sm"
|
|
@@ -669,6 +680,7 @@ async function copyOutput() {
|
|
|
669
680
|
<UButton
|
|
670
681
|
color="primary"
|
|
671
682
|
size="xs"
|
|
683
|
+
data-testid="step-review-comment-add"
|
|
672
684
|
:disabled="!draftBody.trim()"
|
|
673
685
|
@click="addDraftComment"
|
|
674
686
|
>
|
|
@@ -681,6 +693,7 @@ async function copyOutput() {
|
|
|
681
693
|
<div
|
|
682
694
|
v-for="(c, idx) in reviewComments"
|
|
683
695
|
:key="idx"
|
|
696
|
+
data-testid="step-review-comment"
|
|
684
697
|
class="rounded-lg border border-slate-800 bg-slate-900/50 p-3"
|
|
685
698
|
>
|
|
686
699
|
<div class="mb-1 flex items-start justify-between gap-2">
|
|
@@ -709,6 +722,7 @@ async function copyOutput() {
|
|
|
709
722
|
</label>
|
|
710
723
|
<UTextarea
|
|
711
724
|
v-model="feedback"
|
|
725
|
+
data-testid="step-review-feedback"
|
|
712
726
|
:rows="3"
|
|
713
727
|
autoresize
|
|
714
728
|
size="sm"
|
|
@@ -763,6 +777,7 @@ async function copyOutput() {
|
|
|
763
777
|
{{ t('panels.stepDetail.approveAndProceed') }}
|
|
764
778
|
</UButton>
|
|
765
779
|
<UButton
|
|
780
|
+
v-if="proposalEditable"
|
|
766
781
|
color="primary"
|
|
767
782
|
variant="soft"
|
|
768
783
|
size="sm"
|
|
@@ -773,6 +788,9 @@ async function copyOutput() {
|
|
|
773
788
|
>
|
|
774
789
|
{{ t('panels.stepDetail.approveWithCorrections') }}
|
|
775
790
|
</UButton>
|
|
791
|
+
<p v-else class="text-[10px] text-slate-500" data-testid="step-rendered-output-note">
|
|
792
|
+
{{ t('panels.stepDetail.renderedOutputNote') }}
|
|
793
|
+
</p>
|
|
776
794
|
|
|
777
795
|
<!-- destructive: a two-step inline confirm instead of a native dialog -->
|
|
778
796
|
<div
|
|
@@ -812,6 +830,7 @@ async function copyOutput() {
|
|
|
812
830
|
size="sm"
|
|
813
831
|
icon="i-lucide-rotate-ccw"
|
|
814
832
|
class="flex-1"
|
|
833
|
+
data-testid="step-request-changes"
|
|
815
834
|
:disabled="!canRequestChanges"
|
|
816
835
|
:loading="submitting"
|
|
817
836
|
@click="requestChanges"
|
|
@@ -849,126 +868,4 @@ async function copyOutput() {
|
|
|
849
868
|
.reader-fade-leave-to {
|
|
850
869
|
opacity: 0;
|
|
851
870
|
}
|
|
852
|
-
|
|
853
|
-
/* Approval mode: each source-mapped block becomes a comment target — a hover
|
|
854
|
-
highlight + a "+" gutter affordance, GitHub-review style. */
|
|
855
|
-
.reader-prose.review-mode :deep([data-src-start]) {
|
|
856
|
-
position: relative;
|
|
857
|
-
cursor: pointer;
|
|
858
|
-
border-radius: 0.375rem;
|
|
859
|
-
transition: background 0.12s ease;
|
|
860
|
-
}
|
|
861
|
-
.reader-prose.review-mode :deep([data-src-start]:hover) {
|
|
862
|
-
background: rgb(99 102 241 / 0.08);
|
|
863
|
-
box-shadow: inset 2px 0 0 rgb(99 102 241 / 0.5);
|
|
864
|
-
}
|
|
865
|
-
.reader-prose.review-mode :deep([data-src-start])::before {
|
|
866
|
-
content: '+';
|
|
867
|
-
position: absolute;
|
|
868
|
-
left: -1.4rem;
|
|
869
|
-
top: 0.1rem;
|
|
870
|
-
display: none;
|
|
871
|
-
height: 1.1rem;
|
|
872
|
-
width: 1.1rem;
|
|
873
|
-
align-items: center;
|
|
874
|
-
justify-content: center;
|
|
875
|
-
border-radius: 0.25rem;
|
|
876
|
-
background: rgb(99 102 241);
|
|
877
|
-
color: white;
|
|
878
|
-
font-size: 0.8rem;
|
|
879
|
-
line-height: 1;
|
|
880
|
-
}
|
|
881
|
-
.reader-prose.review-mode :deep([data-src-start]:hover)::before {
|
|
882
|
-
display: flex;
|
|
883
|
-
}
|
|
884
|
-
/* Persistent markers: amber for a block that already has a comment, indigo for
|
|
885
|
-
the block whose composer is currently open. */
|
|
886
|
-
.reader-prose :deep(.cf-commented) {
|
|
887
|
-
background: rgb(234 179 8 / 0.1);
|
|
888
|
-
box-shadow: inset 2px 0 0 rgb(234 179 8 / 0.6);
|
|
889
|
-
}
|
|
890
|
-
.reader-prose :deep(.cf-selected) {
|
|
891
|
-
background: rgb(99 102 241 / 0.12);
|
|
892
|
-
box-shadow: inset 2px 0 0 rgb(99 102 241 / 0.8);
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/* Styling for the markdown HTML injected via v-html (out of scoped reach without
|
|
896
|
-
:deep), kept close to the inspector's existing prose styling. */
|
|
897
|
-
.reader-prose :deep(p) {
|
|
898
|
-
margin: 0.5rem 0;
|
|
899
|
-
}
|
|
900
|
-
.reader-prose :deep(ul),
|
|
901
|
-
.reader-prose :deep(ol) {
|
|
902
|
-
margin: 0.5rem 0;
|
|
903
|
-
padding-left: 1.25rem;
|
|
904
|
-
}
|
|
905
|
-
.reader-prose :deep(ul) {
|
|
906
|
-
list-style: disc;
|
|
907
|
-
}
|
|
908
|
-
.reader-prose :deep(ol) {
|
|
909
|
-
list-style: decimal;
|
|
910
|
-
}
|
|
911
|
-
.reader-prose :deep(li) {
|
|
912
|
-
margin: 0.2rem 0;
|
|
913
|
-
}
|
|
914
|
-
.reader-prose :deep(strong) {
|
|
915
|
-
font-weight: 600;
|
|
916
|
-
color: rgb(226 232 240);
|
|
917
|
-
}
|
|
918
|
-
.reader-prose :deep(em) {
|
|
919
|
-
font-style: italic;
|
|
920
|
-
}
|
|
921
|
-
.reader-prose :deep(code) {
|
|
922
|
-
border-radius: 0.25rem;
|
|
923
|
-
background: rgb(30 41 59 / 0.8);
|
|
924
|
-
padding: 0.1rem 0.3rem;
|
|
925
|
-
font-family: ui-monospace, monospace;
|
|
926
|
-
font-size: 0.85em;
|
|
927
|
-
color: rgb(199 210 254);
|
|
928
|
-
}
|
|
929
|
-
.reader-prose :deep(pre) {
|
|
930
|
-
margin: 0.6rem 0;
|
|
931
|
-
overflow: auto;
|
|
932
|
-
border-radius: 0.5rem;
|
|
933
|
-
background: rgb(2 6 23 / 0.6);
|
|
934
|
-
padding: 0.75rem 0.9rem;
|
|
935
|
-
}
|
|
936
|
-
.reader-prose :deep(pre code) {
|
|
937
|
-
background: transparent;
|
|
938
|
-
padding: 0;
|
|
939
|
-
color: rgb(203 213 225);
|
|
940
|
-
}
|
|
941
|
-
.reader-prose :deep(blockquote) {
|
|
942
|
-
margin: 0.6rem 0;
|
|
943
|
-
border-left: 3px solid rgb(99 102 241 / 0.5);
|
|
944
|
-
padding-left: 0.75rem;
|
|
945
|
-
color: rgb(148 163 184);
|
|
946
|
-
}
|
|
947
|
-
.reader-prose :deep(table) {
|
|
948
|
-
margin: 0.6rem 0;
|
|
949
|
-
border-collapse: collapse;
|
|
950
|
-
font-size: 0.95em;
|
|
951
|
-
}
|
|
952
|
-
.reader-prose :deep(th),
|
|
953
|
-
.reader-prose :deep(td) {
|
|
954
|
-
border: 1px solid rgb(51 65 85);
|
|
955
|
-
padding: 0.3rem 0.6rem;
|
|
956
|
-
}
|
|
957
|
-
.reader-prose :deep(th) {
|
|
958
|
-
background: rgb(30 41 59 / 0.6);
|
|
959
|
-
font-weight: 600;
|
|
960
|
-
}
|
|
961
|
-
.reader-prose :deep(hr) {
|
|
962
|
-
margin: 1rem 0;
|
|
963
|
-
border: none;
|
|
964
|
-
border-top: 1px solid rgb(51 65 85);
|
|
965
|
-
}
|
|
966
|
-
.reader-prose :deep(h1),
|
|
967
|
-
.reader-prose :deep(h2),
|
|
968
|
-
.reader-prose :deep(h3),
|
|
969
|
-
.reader-prose :deep(h4) {
|
|
970
|
-
margin: 0.6rem 0 0.3rem;
|
|
971
|
-
font-weight: 600;
|
|
972
|
-
color: rgb(226 232 240);
|
|
973
|
-
}
|
|
974
871
|
</style>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The pull request a `review` task is reviewing — its SUBJECT, not its output. Distinct from the
|
|
3
|
+
// Execution panel's "Pull request", which links the PR a run PRODUCED: a review task produces no
|
|
4
|
+
// PR at all, so without this the one thing identifying the task was buried in the folded
|
|
5
|
+
// description text with nothing to click.
|
|
6
|
+
//
|
|
7
|
+
// Reads the task's own fields, so it is there from creation — before any run — and stays there
|
|
8
|
+
// after one. The backend confirms the PR against the provider at create time and records that
|
|
9
|
+
// provider's own link (`prUrl`), which is what makes a plain read enough here; a task created
|
|
10
|
+
// while no VCS was connected keeps only the number, and then the reference reads as text rather
|
|
11
|
+
// than pretending to be a link.
|
|
12
|
+
import { computed } from 'vue'
|
|
13
|
+
import type { Block } from '~/types/domain'
|
|
14
|
+
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ block: Block }>()
|
|
17
|
+
const { t } = useI18n()
|
|
18
|
+
|
|
19
|
+
const isReview = computed(() => props.block.taskType === 'review')
|
|
20
|
+
const fields = computed(() => props.block.taskTypeFields ?? null)
|
|
21
|
+
const url = computed(() => fields.value?.prUrl?.trim() || '')
|
|
22
|
+
const focus = computed(() => fields.value?.reviewFocus?.trim() || '')
|
|
23
|
+
|
|
24
|
+
/** `#123` when the number is known, else the raw link — never an empty affordance. */
|
|
25
|
+
const label = computed(() => {
|
|
26
|
+
const number = fields.value?.prNumber
|
|
27
|
+
return number ? t('inspector.reviewTarget.prNumber', { number }) : url.value
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
/** Nothing to show when the task carries no reference at all (nothing to link or name). */
|
|
31
|
+
const hasTarget = computed(() => Boolean(label.value))
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<InspectorSection
|
|
36
|
+
v-if="isReview && hasTarget"
|
|
37
|
+
:title="t('inspector.reviewTarget.title')"
|
|
38
|
+
:hint="t('inspector.reviewTarget.hint')"
|
|
39
|
+
icon="i-lucide-git-pull-request-arrow"
|
|
40
|
+
default-open
|
|
41
|
+
>
|
|
42
|
+
<UButton
|
|
43
|
+
v-if="url"
|
|
44
|
+
:to="url"
|
|
45
|
+
target="_blank"
|
|
46
|
+
rel="noopener"
|
|
47
|
+
external
|
|
48
|
+
color="neutral"
|
|
49
|
+
variant="soft"
|
|
50
|
+
size="sm"
|
|
51
|
+
icon="i-lucide-git-pull-request"
|
|
52
|
+
trailing-icon="i-lucide-external-link"
|
|
53
|
+
block
|
|
54
|
+
data-testid="inspector-review-target-link"
|
|
55
|
+
>
|
|
56
|
+
<span class="w-full truncate text-start" :title="url">{{ label }}</span>
|
|
57
|
+
</UButton>
|
|
58
|
+
<p
|
|
59
|
+
v-else
|
|
60
|
+
class="rounded-lg border border-slate-800 bg-slate-900/40 p-2.5 text-xs text-slate-300"
|
|
61
|
+
data-testid="inspector-review-target-link"
|
|
62
|
+
>
|
|
63
|
+
{{ label }}
|
|
64
|
+
</p>
|
|
65
|
+
<p v-if="focus" class="text-xs leading-relaxed text-slate-500">
|
|
66
|
+
{{ t('inspector.reviewTarget.focus', { focus }) }}
|
|
67
|
+
</p>
|
|
68
|
+
</InspectorSection>
|
|
69
|
+
</template>
|
|
@@ -1228,41 +1228,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
1228
1228
|
.pl-5\.5 {
|
|
1229
1229
|
padding-left: 1.375rem;
|
|
1230
1230
|
}
|
|
1231
|
-
/*
|
|
1232
|
-
prose
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
}
|
|
1236
|
-
.reader-prose :deep(ul),
|
|
1237
|
-
.reader-prose :deep(ol) {
|
|
1238
|
-
margin: 0.4rem 0;
|
|
1239
|
-
padding-left: 1.25rem;
|
|
1240
|
-
list-style: revert;
|
|
1241
|
-
}
|
|
1242
|
-
.reader-prose :deep(li) {
|
|
1243
|
-
margin: 0.2rem 0;
|
|
1244
|
-
}
|
|
1245
|
-
.reader-prose :deep(strong) {
|
|
1246
|
-
color: rgb(226 232 240);
|
|
1247
|
-
font-weight: 600;
|
|
1248
|
-
}
|
|
1249
|
-
.reader-prose :deep(code) {
|
|
1250
|
-
border-radius: 0.25rem;
|
|
1251
|
-
background: rgb(2 6 23 / 0.6);
|
|
1252
|
-
padding: 0.05rem 0.3rem;
|
|
1253
|
-
font-size: 0.85em;
|
|
1254
|
-
}
|
|
1255
|
-
.reader-prose :deep(pre) {
|
|
1256
|
-
margin: 0.5rem 0;
|
|
1257
|
-
overflow-x: auto;
|
|
1258
|
-
border-radius: 0.5rem;
|
|
1259
|
-
background: rgb(2 6 23 / 0.6);
|
|
1260
|
-
padding: 0.75rem;
|
|
1261
|
-
}
|
|
1262
|
-
.reader-prose :deep(blockquote) {
|
|
1263
|
-
margin: 0.5rem 0;
|
|
1264
|
-
border-left: 2px solid rgb(51 65 85);
|
|
1265
|
-
padding-left: 0.75rem;
|
|
1266
|
-
color: rgb(148 163 184);
|
|
1267
|
-
}
|
|
1231
|
+
/* The rendered-markdown presentation is the SHARED global `.reader-prose` sheet
|
|
1232
|
+
(`assets/css/prose.css`), not a local copy: this reader shows the same agent-authored
|
|
1233
|
+
markdown the step reader does, and a per-window duplicate is how the review surfaces
|
|
1234
|
+
drift apart one property at a time. */
|
|
1268
1235
|
</style>
|
|
@@ -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,
|