@cat-factory/app 0.185.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/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 +14 -3
- package/i18n/locales/en.json +14 -3
- package/i18n/locales/es.json +14 -3
- package/i18n/locales/fr.json +14 -3
- package/i18n/locales/he.json +14 -3
- package/i18n/locales/it.json +14 -3
- package/i18n/locales/ja.json +14 -3
- package/i18n/locales/pl.json +14 -3
- package/i18n/locales/tr.json +14 -3
- package/i18n/locales/uk.json +14 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -142,7 +142,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
142
142
|
the board.
|
|
143
143
|
- **Inspector** (`components/panels` + `panels/inspector`) — per-block tabs:
|
|
144
144
|
structure, dependencies, model + fragment picker, live execution, and linked
|
|
145
|
-
docs/issues/scenarios. Decisions resolve via `DecisionModal`.
|
|
145
|
+
docs/issues/scenarios. Decisions resolve via `DecisionModal`. A `review` task
|
|
146
|
+
additionally leads with `TaskReviewTarget`, linking the pull request it reviews —
|
|
147
|
+
distinct from the execution panel's link to the PR a run PRODUCED, which a review
|
|
148
|
+
task never has.
|
|
146
149
|
- **Pipeline builder** (`components/pipeline`) — assemble/edit agent chains and
|
|
147
150
|
watch `PipelineProgress`. `PipelinePicker` (+ its `PipelinePreview` pane) is the
|
|
148
151
|
single way a pipeline is chosen anywhere — add-task, run settings, the recurring
|
package/app/assets/css/main.css
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
@import 'tailwindcss';
|
|
2
2
|
@import '@nuxt/ui';
|
|
3
|
+
/* The shared rendered-markdown reader presentation (see prose.css). */
|
|
4
|
+
@import './prose.css';
|
|
3
5
|
|
|
4
6
|
/* Toast text should be selectable. Reka UI sets `user-select: none` inline on
|
|
5
7
|
* the toast root (to support swipe-to-dismiss); since `user-select` is
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The rendered-markdown reader's presentation, shared by EVERY surface that reviews an agent's
|
|
3
|
+
* prose: the step-detail overlay (`AgentStepDetail`), the initiative tracker's plan-approval
|
|
4
|
+
* rail, and the clarity / requirements / brainstorm review windows. Global rather than scoped
|
|
5
|
+
* because the markup is injected with `v-html`, so a scoped sheet would need `:deep()` in each
|
|
6
|
+
* consumer — which is exactly what those windows each used to carry, five near-identical copies
|
|
7
|
+
* diverging a property at a time. "Consistent in look and feel across review surfaces" is the
|
|
8
|
+
* whole point, so the copy lives here once and a new reader adds `.reader-prose` and nothing else.
|
|
9
|
+
*
|
|
10
|
+
* `.reader-prose` styles the document; `.review-mode` turns each source-mapped block into a
|
|
11
|
+
* comment target (hover highlight + a "+" gutter affordance, GitHub-review style); `.cf-commented`
|
|
12
|
+
* / `.cf-selected` are the persistent markers `useProseComments` toggles.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* Approval mode: each source-mapped block becomes a comment target — a hover
|
|
16
|
+
highlight + a "+" gutter affordance, GitHub-review style. */
|
|
17
|
+
.reader-prose.review-mode [data-src-start] {
|
|
18
|
+
position: relative;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
border-radius: 0.375rem;
|
|
21
|
+
transition: background 0.12s ease;
|
|
22
|
+
}
|
|
23
|
+
.reader-prose.review-mode [data-src-start]:hover {
|
|
24
|
+
background: rgb(99 102 241 / 0.08);
|
|
25
|
+
box-shadow: inset 2px 0 0 rgb(99 102 241 / 0.5);
|
|
26
|
+
}
|
|
27
|
+
.reader-prose.review-mode [data-src-start]::before {
|
|
28
|
+
content: '+';
|
|
29
|
+
position: absolute;
|
|
30
|
+
left: -1.4rem;
|
|
31
|
+
top: 0.1rem;
|
|
32
|
+
display: none;
|
|
33
|
+
height: 1.1rem;
|
|
34
|
+
width: 1.1rem;
|
|
35
|
+
align-items: center;
|
|
36
|
+
justify-content: center;
|
|
37
|
+
border-radius: 0.25rem;
|
|
38
|
+
background: rgb(99 102 241);
|
|
39
|
+
color: white;
|
|
40
|
+
font-size: 0.8rem;
|
|
41
|
+
line-height: 1;
|
|
42
|
+
}
|
|
43
|
+
.reader-prose.review-mode [data-src-start]:hover::before {
|
|
44
|
+
display: flex;
|
|
45
|
+
}
|
|
46
|
+
/* Persistent markers: amber for a block that already has a comment, indigo for
|
|
47
|
+
the block whose composer is currently open. */
|
|
48
|
+
.reader-prose .cf-commented {
|
|
49
|
+
background: rgb(234 179 8 / 0.1);
|
|
50
|
+
box-shadow: inset 2px 0 0 rgb(234 179 8 / 0.6);
|
|
51
|
+
}
|
|
52
|
+
.reader-prose .cf-selected {
|
|
53
|
+
background: rgb(99 102 241 / 0.12);
|
|
54
|
+
box-shadow: inset 2px 0 0 rgb(99 102 241 / 0.8);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/* Styling for the markdown HTML injected via v-html (out of scoped reach without
|
|
58
|
+
:deep), kept close to the inspector's existing prose styling. */
|
|
59
|
+
.reader-prose p {
|
|
60
|
+
margin: 0.5rem 0;
|
|
61
|
+
}
|
|
62
|
+
.reader-prose ul,
|
|
63
|
+
.reader-prose ol {
|
|
64
|
+
margin: 0.5rem 0;
|
|
65
|
+
padding-left: 1.25rem;
|
|
66
|
+
}
|
|
67
|
+
.reader-prose ul {
|
|
68
|
+
list-style: disc;
|
|
69
|
+
}
|
|
70
|
+
.reader-prose ol {
|
|
71
|
+
list-style: decimal;
|
|
72
|
+
}
|
|
73
|
+
.reader-prose li {
|
|
74
|
+
margin: 0.2rem 0;
|
|
75
|
+
}
|
|
76
|
+
.reader-prose strong {
|
|
77
|
+
font-weight: 600;
|
|
78
|
+
color: rgb(226 232 240);
|
|
79
|
+
}
|
|
80
|
+
.reader-prose em {
|
|
81
|
+
font-style: italic;
|
|
82
|
+
}
|
|
83
|
+
.reader-prose code {
|
|
84
|
+
border-radius: 0.25rem;
|
|
85
|
+
background: rgb(30 41 59 / 0.8);
|
|
86
|
+
padding: 0.1rem 0.3rem;
|
|
87
|
+
font-family: ui-monospace, monospace;
|
|
88
|
+
font-size: 0.85em;
|
|
89
|
+
color: rgb(199 210 254);
|
|
90
|
+
}
|
|
91
|
+
.reader-prose pre {
|
|
92
|
+
margin: 0.6rem 0;
|
|
93
|
+
overflow: auto;
|
|
94
|
+
border-radius: 0.5rem;
|
|
95
|
+
background: rgb(2 6 23 / 0.6);
|
|
96
|
+
padding: 0.75rem 0.9rem;
|
|
97
|
+
}
|
|
98
|
+
.reader-prose pre code {
|
|
99
|
+
background: transparent;
|
|
100
|
+
padding: 0;
|
|
101
|
+
color: rgb(203 213 225);
|
|
102
|
+
}
|
|
103
|
+
.reader-prose blockquote {
|
|
104
|
+
margin: 0.6rem 0;
|
|
105
|
+
border-left: 3px solid rgb(99 102 241 / 0.5);
|
|
106
|
+
padding-left: 0.75rem;
|
|
107
|
+
color: rgb(148 163 184);
|
|
108
|
+
}
|
|
109
|
+
.reader-prose table {
|
|
110
|
+
margin: 0.6rem 0;
|
|
111
|
+
border-collapse: collapse;
|
|
112
|
+
font-size: 0.95em;
|
|
113
|
+
}
|
|
114
|
+
.reader-prose th,
|
|
115
|
+
.reader-prose td {
|
|
116
|
+
border: 1px solid rgb(51 65 85);
|
|
117
|
+
padding: 0.3rem 0.6rem;
|
|
118
|
+
}
|
|
119
|
+
.reader-prose th {
|
|
120
|
+
background: rgb(30 41 59 / 0.6);
|
|
121
|
+
font-weight: 600;
|
|
122
|
+
}
|
|
123
|
+
.reader-prose hr {
|
|
124
|
+
margin: 1rem 0;
|
|
125
|
+
border: none;
|
|
126
|
+
border-top: 1px solid rgb(51 65 85);
|
|
127
|
+
}
|
|
128
|
+
.reader-prose h1,
|
|
129
|
+
.reader-prose h2,
|
|
130
|
+
.reader-prose h3,
|
|
131
|
+
.reader-prose h4 {
|
|
132
|
+
margin: 0.6rem 0 0.3rem;
|
|
133
|
+
font-weight: 600;
|
|
134
|
+
color: rgb(226 232 240);
|
|
135
|
+
}
|
|
@@ -28,6 +28,8 @@ import ContextAttachmentFields from '~/components/context/ContextAttachmentField
|
|
|
28
28
|
import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
|
|
29
29
|
import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
|
|
30
30
|
import { parseConflict } from '~/composables/usePipelineErrorToast'
|
|
31
|
+
import { apiErrorEnvelope } from '~/composables/api/errors'
|
|
32
|
+
import type { ReviewTargetReason } from '@cat-factory/contracts'
|
|
31
33
|
import { pipelineAllowedForManualStart } from '~/utils/pipeline'
|
|
32
34
|
|
|
33
35
|
const ui = useUiStore()
|
|
@@ -680,7 +682,7 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
|
680
682
|
}
|
|
681
683
|
toast.add({
|
|
682
684
|
title: t('board.addTask.addFailedTitle'),
|
|
683
|
-
description: e instanceof Error ? e.message : String(e),
|
|
685
|
+
description: reviewTargetMessage(e) ?? (e instanceof Error ? e.message : String(e)),
|
|
684
686
|
icon: 'i-lucide-triangle-alert',
|
|
685
687
|
color: 'error',
|
|
686
688
|
})
|
|
@@ -689,6 +691,34 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
|
|
|
689
691
|
}
|
|
690
692
|
}
|
|
691
693
|
|
|
694
|
+
// The backend validates a review task's target PR against the service's repo before creating
|
|
695
|
+
// anything, and refuses with a machine-readable reason. Map it to translated copy here (the
|
|
696
|
+
// backend does not localize prose); an unrecognised failure keeps the raw message.
|
|
697
|
+
// Exhaustive over the closed union, so a new reason fails the typecheck rather than silently
|
|
698
|
+
// falling through to an English sentence from the server.
|
|
699
|
+
// Each message names what the user got wrong, so a detail the backend didn't send would leave a
|
|
700
|
+
// hole in the sentence: those cases return null and the server's own prose is shown instead.
|
|
701
|
+
const REVIEW_TARGET_MESSAGES: Record<
|
|
702
|
+
ReviewTargetReason,
|
|
703
|
+
(details: Record<string, unknown>) => string | null
|
|
704
|
+
> = {
|
|
705
|
+
review_pr_not_found: (d) =>
|
|
706
|
+
typeof d.prNumber === 'number'
|
|
707
|
+
? t('board.addTask.review.prNotFound', { number: d.prNumber })
|
|
708
|
+
: null,
|
|
709
|
+
review_pr_repo_mismatch: (d) =>
|
|
710
|
+
typeof d.expected === 'string'
|
|
711
|
+
? t('board.addTask.review.prRepoMismatch', { repo: d.expected })
|
|
712
|
+
: null,
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function reviewTargetMessage(error: unknown): string | null {
|
|
716
|
+
const details = (apiErrorEnvelope(error)?.details ?? {}) as Record<string, unknown>
|
|
717
|
+
const reason = details.reason
|
|
718
|
+
if (typeof reason !== 'string') return null
|
|
719
|
+
return REVIEW_TARGET_MESSAGES[reason as ReviewTargetReason]?.(details) ?? null
|
|
720
|
+
}
|
|
721
|
+
|
|
692
722
|
/** Turn a parsed review-debt friction 409 into the dialog context (see ReviewFrictionDialog.vue). */
|
|
693
723
|
function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseConflict>>) {
|
|
694
724
|
const details = conflict.details
|
|
@@ -610,41 +610,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
610
610
|
.pl-5\.5 {
|
|
611
611
|
padding-left: 1.375rem;
|
|
612
612
|
}
|
|
613
|
-
/*
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
.reader-prose :deep(ul),
|
|
619
|
-
.reader-prose :deep(ol) {
|
|
620
|
-
margin: 0.4rem 0;
|
|
621
|
-
padding-left: 1.25rem;
|
|
622
|
-
list-style: revert;
|
|
623
|
-
}
|
|
624
|
-
.reader-prose :deep(li) {
|
|
625
|
-
margin: 0.2rem 0;
|
|
626
|
-
}
|
|
627
|
-
.reader-prose :deep(strong) {
|
|
628
|
-
color: rgb(226 232 240);
|
|
629
|
-
font-weight: 600;
|
|
630
|
-
}
|
|
631
|
-
.reader-prose :deep(code) {
|
|
632
|
-
border-radius: 0.25rem;
|
|
633
|
-
background: rgb(2 6 23 / 0.6);
|
|
634
|
-
padding: 0.05rem 0.3rem;
|
|
635
|
-
font-size: 0.85em;
|
|
636
|
-
}
|
|
637
|
-
.reader-prose :deep(pre) {
|
|
638
|
-
margin: 0.5rem 0;
|
|
639
|
-
overflow-x: auto;
|
|
640
|
-
border-radius: 0.5rem;
|
|
641
|
-
background: rgb(2 6 23 / 0.6);
|
|
642
|
-
padding: 0.75rem;
|
|
643
|
-
}
|
|
644
|
-
.reader-prose :deep(blockquote) {
|
|
645
|
-
margin: 0.5rem 0;
|
|
646
|
-
border-left: 2px solid rgb(51 65 85);
|
|
647
|
-
padding-left: 0.75rem;
|
|
648
|
-
color: rgb(148 163 184);
|
|
649
|
-
}
|
|
613
|
+
/* The rendered-markdown presentation is the SHARED global `.reader-prose` sheet
|
|
614
|
+
(`assets/css/prose.css`), not a local copy: this reader shows the same agent-authored
|
|
615
|
+
markdown the step reader does, and a per-window duplicate is how the review surfaces
|
|
616
|
+
drift apart one property at a time. */
|
|
650
617
|
</style>
|
|
@@ -658,41 +658,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
658
658
|
.pl-5\.5 {
|
|
659
659
|
padding-left: 1.375rem;
|
|
660
660
|
}
|
|
661
|
-
/*
|
|
662
|
-
prose
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
}
|
|
666
|
-
.reader-prose :deep(ul),
|
|
667
|
-
.reader-prose :deep(ol) {
|
|
668
|
-
margin: 0.4rem 0;
|
|
669
|
-
padding-left: 1.25rem;
|
|
670
|
-
list-style: revert;
|
|
671
|
-
}
|
|
672
|
-
.reader-prose :deep(li) {
|
|
673
|
-
margin: 0.2rem 0;
|
|
674
|
-
}
|
|
675
|
-
.reader-prose :deep(strong) {
|
|
676
|
-
color: rgb(226 232 240);
|
|
677
|
-
font-weight: 600;
|
|
678
|
-
}
|
|
679
|
-
.reader-prose :deep(code) {
|
|
680
|
-
border-radius: 0.25rem;
|
|
681
|
-
background: rgb(2 6 23 / 0.6);
|
|
682
|
-
padding: 0.05rem 0.3rem;
|
|
683
|
-
font-size: 0.85em;
|
|
684
|
-
}
|
|
685
|
-
.reader-prose :deep(pre) {
|
|
686
|
-
margin: 0.5rem 0;
|
|
687
|
-
overflow-x: auto;
|
|
688
|
-
border-radius: 0.5rem;
|
|
689
|
-
background: rgb(2 6 23 / 0.6);
|
|
690
|
-
padding: 0.75rem;
|
|
691
|
-
}
|
|
692
|
-
.reader-prose :deep(blockquote) {
|
|
693
|
-
margin: 0.5rem 0;
|
|
694
|
-
border-left: 2px solid rgb(51 65 85);
|
|
695
|
-
padding-left: 0.75rem;
|
|
696
|
-
color: rgb(148 163 184);
|
|
697
|
-
}
|
|
661
|
+
/* The rendered-markdown presentation is the SHARED global `.reader-prose` sheet
|
|
662
|
+
(`assets/css/prose.css`), not a local copy: this reader shows the same agent-authored
|
|
663
|
+
markdown the step reader does, and a per-window duplicate is how the review surfaces
|
|
664
|
+
drift apart one property at a time. */
|
|
698
665
|
</style>
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The planner's human gate, reviewed on the PLAN — the tracker window's plan-approval rail.
|
|
3
|
+
//
|
|
4
|
+
// The rail this replaces could approve or send back, but it judged nothing: the plan was a wall
|
|
5
|
+
// of structured sections above it, with no way to navigate a long one and no way to say WHICH
|
|
6
|
+
// part needed changing. So it renders the plan as the document it is — the engine puts a markdown
|
|
7
|
+
// rendering of the INGESTED plan on the gate's proposal (`renderInitiativePlanForReview`, authored
|
|
8
|
+
// by the planner's step resolver, which is the only thing that knows what ingest committed) — and
|
|
9
|
+
// gives it the same three tools the step reader gives the architect's prose: an outline to
|
|
10
|
+
// navigate by, click-to-comment on any block, and overall feedback. Everything here is shared with
|
|
11
|
+
// that reader rather than re-implemented: `useStepProse` for the outline/collapse/scroll-spy,
|
|
12
|
+
// `useProseComments` for the anchoring, and the global `.reader-prose` sheet for the presentation,
|
|
13
|
+
// so the surfaces cannot drift.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately NOT offered here: "approve with corrections". The plan was ingested into the
|
|
16
|
+
// `initiatives` entity before this gate was raised, so the document is a VIEW of committed state
|
|
17
|
+
// — an edit typed over it would reach nothing, and the engine refuses it outright
|
|
18
|
+
// (`outputIsRendered` → 422). Requesting changes is the route for a correction, which is why a
|
|
19
|
+
// comment is worth anchoring: it quotes the planner's own text back to it on the re-plan.
|
|
20
|
+
import { computed, ref, watch } from 'vue'
|
|
21
|
+
import type { StepApproval } from '~/types/execution'
|
|
22
|
+
import { useStepProse } from '~/composables/useStepProse'
|
|
23
|
+
import { useProseComments } from '~/composables/useProseComments'
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
/** The parked gate: its `proposal` is the rendered plan document under review. */
|
|
27
|
+
approval: StepApproval
|
|
28
|
+
/** The run the gate belongs to, for the approve / request-changes commands. */
|
|
29
|
+
instanceId: string
|
|
30
|
+
/** Whether the viewer may resolve runs at all (RBAC); false renders the actions disabled. */
|
|
31
|
+
canExecute: boolean
|
|
32
|
+
/**
|
|
33
|
+
* Whether the proposal IS the plan rendering (the step's `outputIsRendered`). Read rather
|
|
34
|
+
* than inferred from the proposal being non-empty: a run planned before the engine rendered
|
|
35
|
+
* plans parks on the planner's transcript summary, which is a perfectly non-empty string —
|
|
36
|
+
* so an emptiness check would show that one sentence under a table of contents as if it were
|
|
37
|
+
* the plan, which is the failure this whole surface exists to end.
|
|
38
|
+
*/
|
|
39
|
+
outputIsRendered: boolean
|
|
40
|
+
}>()
|
|
41
|
+
|
|
42
|
+
const execution = useExecutionStore()
|
|
43
|
+
const { t } = useI18n()
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The plan document under review — the gate's proposal, but ONLY once the step says that
|
|
47
|
+
* proposal is the plan rendering. Not named `document`: that shadows the global in a template
|
|
48
|
+
* expression. Anything else (a run planned before the engine rendered plans, which parks on the
|
|
49
|
+
* planner's transcript summary) reads as no document, and the `noDocument` notice says so rather
|
|
50
|
+
* than dressing a stray sentence up as the plan.
|
|
51
|
+
*/
|
|
52
|
+
const planDocument = computed(() => (props.outputIsRendered ? (props.approval.proposal ?? '') : ''))
|
|
53
|
+
|
|
54
|
+
// The outline + collapse + scroll-spy, exactly as the step reader resolves them — minus its
|
|
55
|
+
// lead anchor: the reader renders a details card ahead of the prose and this rail renders the
|
|
56
|
+
// document alone, and the spy stops at the first anchor it cannot measure.
|
|
57
|
+
const prose = useStepProse(() => planDocument.value, { leadAnchorId: null })
|
|
58
|
+
const {
|
|
59
|
+
outline,
|
|
60
|
+
tocSections,
|
|
61
|
+
hasOutput,
|
|
62
|
+
collapsed,
|
|
63
|
+
activeId,
|
|
64
|
+
// The reader's own scroll container, bound straight through so the shared scroll-spy and the
|
|
65
|
+
// comment-highlight sync read the same element the template scrolls.
|
|
66
|
+
scrollEl,
|
|
67
|
+
sectionEls,
|
|
68
|
+
toggle,
|
|
69
|
+
goTo,
|
|
70
|
+
onScroll,
|
|
71
|
+
} = prose
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Per-block comment drafts over the plan, anchored to its source lines. Commenting follows the
|
|
75
|
+
* same RBAC gate as the actions: a viewer who cannot resolve the run cannot send the comments
|
|
76
|
+
* anywhere, so offering the composer would only invite work the Send-back button then refuses.
|
|
77
|
+
*/
|
|
78
|
+
const {
|
|
79
|
+
comments: planComments,
|
|
80
|
+
wireComments,
|
|
81
|
+
draftTarget,
|
|
82
|
+
draftBody,
|
|
83
|
+
onProseClick,
|
|
84
|
+
addDraftComment,
|
|
85
|
+
cancelDraft,
|
|
86
|
+
removeComment,
|
|
87
|
+
reset: resetComments,
|
|
88
|
+
} = useProseComments({
|
|
89
|
+
output: () => planDocument.value,
|
|
90
|
+
root: () => scrollEl.value,
|
|
91
|
+
enabled: () => props.canExecute,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const feedback = ref('')
|
|
95
|
+
const submitting = ref(false)
|
|
96
|
+
|
|
97
|
+
/** Changes can only be requested with something to act on — an empty send would re-plan blind. */
|
|
98
|
+
const canRequestChanges = computed(() => !!feedback.value.trim() || planComments.value.length > 0)
|
|
99
|
+
|
|
100
|
+
/** A fresh gate (a re-plan parked again) drops the drafts rather than carrying them over. */
|
|
101
|
+
watch(
|
|
102
|
+
() => props.approval.id,
|
|
103
|
+
() => {
|
|
104
|
+
resetComments()
|
|
105
|
+
feedback.value = ''
|
|
106
|
+
prose.reset()
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Accept the plan: the run advances to the committer, which persists it and arms the execution
|
|
112
|
+
* loop. The window stays open — the rail disappears with the approval (live) and the tracker is
|
|
113
|
+
* where the plan then executes.
|
|
114
|
+
*/
|
|
115
|
+
async function approve() {
|
|
116
|
+
if (submitting.value || !props.canExecute) return
|
|
117
|
+
submitting.value = true
|
|
118
|
+
try {
|
|
119
|
+
await execution.approveStep(props.instanceId, props.approval.id)
|
|
120
|
+
} finally {
|
|
121
|
+
submitting.value = false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Send the plan back: the planner re-plans from the feedback + the anchored comments. */
|
|
126
|
+
async function requestChanges() {
|
|
127
|
+
if (submitting.value || !canRequestChanges.value || !props.canExecute) return
|
|
128
|
+
submitting.value = true
|
|
129
|
+
try {
|
|
130
|
+
const ok = await execution.requestStepChanges(props.instanceId, props.approval.id, {
|
|
131
|
+
feedback: feedback.value.trim() || undefined,
|
|
132
|
+
comments: wireComments.value,
|
|
133
|
+
})
|
|
134
|
+
if (ok) {
|
|
135
|
+
feedback.value = ''
|
|
136
|
+
resetComments()
|
|
137
|
+
}
|
|
138
|
+
} finally {
|
|
139
|
+
submitting.value = false
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const disabledTitle = computed(() => (props.canExecute ? undefined : t('access.noRunExecute')))
|
|
144
|
+
</script>
|
|
145
|
+
|
|
146
|
+
<template>
|
|
147
|
+
<section
|
|
148
|
+
class="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10"
|
|
149
|
+
data-testid="initiative-plan-review"
|
|
150
|
+
>
|
|
151
|
+
<header class="flex items-start gap-2.5 px-3.5 pt-3.5">
|
|
152
|
+
<UIcon name="i-lucide-clipboard-check" class="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
|
|
153
|
+
<div class="min-w-0 flex-1">
|
|
154
|
+
<h3 class="text-[13px] font-semibold text-amber-200">
|
|
155
|
+
{{ t('initiative.planReview.title') }}
|
|
156
|
+
</h3>
|
|
157
|
+
<p class="mt-0.5 text-[12px] leading-relaxed text-amber-100/80">
|
|
158
|
+
{{ t('initiative.planReview.body') }}
|
|
159
|
+
</p>
|
|
160
|
+
</div>
|
|
161
|
+
</header>
|
|
162
|
+
|
|
163
|
+
<!-- The plan document, shown only when the gate's proposal actually IS the plan rendering
|
|
164
|
+
(`outputIsRendered`). A run planned before the engine rendered plans parks on the
|
|
165
|
+
planner's transcript summary instead, so it takes the notice below — the structured
|
|
166
|
+
sections further down are still the plan in that case. -->
|
|
167
|
+
<div v-if="hasOutput" class="mt-3 flex min-h-0 gap-3 px-3.5">
|
|
168
|
+
<!-- Outline: the navigation the rail had none of. Inline rather than a full sidebar — the
|
|
169
|
+
tracker window already spends its end-side column on run metadata. -->
|
|
170
|
+
<nav
|
|
171
|
+
v-if="outline.hasToc"
|
|
172
|
+
data-testid="initiative-plan-toc"
|
|
173
|
+
class="hidden max-h-80 w-48 shrink-0 space-y-0.5 overflow-y-auto border-e border-amber-500/20 pe-2 md:block"
|
|
174
|
+
>
|
|
175
|
+
<button
|
|
176
|
+
v-for="s in tocSections"
|
|
177
|
+
:key="s.id"
|
|
178
|
+
class="block w-full truncate rounded px-1.5 py-1 text-start text-[12px] transition"
|
|
179
|
+
:class="
|
|
180
|
+
activeId === s.id
|
|
181
|
+
? 'bg-amber-500/20 font-medium text-amber-100'
|
|
182
|
+
: 'text-amber-200/60 hover:bg-amber-500/10 hover:text-amber-100'
|
|
183
|
+
"
|
|
184
|
+
:style="{ paddingLeft: `${(s.depth - outline.minDepth) * 0.7 + 0.4}rem` }"
|
|
185
|
+
:title="s.title"
|
|
186
|
+
@click="goTo(s.id)"
|
|
187
|
+
>
|
|
188
|
+
{{ s.title }}
|
|
189
|
+
</button>
|
|
190
|
+
</nav>
|
|
191
|
+
|
|
192
|
+
<div
|
|
193
|
+
ref="scrollEl"
|
|
194
|
+
data-testid="initiative-plan-document"
|
|
195
|
+
class="max-h-80 min-w-0 flex-1 overflow-y-auto rounded border border-amber-500/20 bg-slate-950/40 p-3"
|
|
196
|
+
@scroll="onScroll"
|
|
197
|
+
>
|
|
198
|
+
<section
|
|
199
|
+
v-for="s in outline.sections"
|
|
200
|
+
:id="s.id"
|
|
201
|
+
:key="s.id"
|
|
202
|
+
:ref="(el) => (sectionEls[s.id] = el as HTMLElement | null)"
|
|
203
|
+
class="scroll-mt-2"
|
|
204
|
+
>
|
|
205
|
+
<button
|
|
206
|
+
v-if="s.depth > 0"
|
|
207
|
+
class="group flex w-full items-center gap-1.5 rounded py-0.5 text-start transition hover:text-white"
|
|
208
|
+
@click="toggle(s.id)"
|
|
209
|
+
>
|
|
210
|
+
<UIcon
|
|
211
|
+
name="i-lucide-chevron-right"
|
|
212
|
+
class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform"
|
|
213
|
+
:class="collapsed[s.id] ? '' : 'rotate-90'"
|
|
214
|
+
/>
|
|
215
|
+
<span
|
|
216
|
+
class="font-semibold text-slate-100"
|
|
217
|
+
:class="s.depth <= 1 ? 'text-sm' : 'text-[13px]'"
|
|
218
|
+
v-html="s.titleHtml"
|
|
219
|
+
/>
|
|
220
|
+
</button>
|
|
221
|
+
<!-- `review-mode` carries the click-to-comment affordance, so it tracks the same RBAC
|
|
222
|
+
gate the composer does — a viewer gets the document, not hover targets that lead
|
|
223
|
+
nowhere. -->
|
|
224
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
225
|
+
<div
|
|
226
|
+
v-show="!collapsed[s.id]"
|
|
227
|
+
class="reader-prose mt-0.5 text-[12px] leading-relaxed text-slate-300"
|
|
228
|
+
:class="[s.depth > 0 ? 'ps-5' : '', canExecute ? 'review-mode' : '']"
|
|
229
|
+
@click="onProseClick"
|
|
230
|
+
v-html="s.bodyHtml"
|
|
231
|
+
/>
|
|
232
|
+
</section>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
<p v-else class="mt-2 px-3.5 text-[12px] text-amber-100/70">
|
|
236
|
+
{{ t('initiative.planReview.noDocument') }}
|
|
237
|
+
</p>
|
|
238
|
+
|
|
239
|
+
<!-- Comment composer for the block just clicked, then the anchored comments so far. -->
|
|
240
|
+
<div v-if="draftTarget" class="mt-3 px-3.5" data-testid="initiative-plan-composer">
|
|
241
|
+
<div class="rounded-lg border border-indigo-500/40 bg-indigo-500/5 p-2.5">
|
|
242
|
+
<div class="mb-1 text-[10px] uppercase tracking-wide text-indigo-300">
|
|
243
|
+
{{ t('panels.stepDetail.commentingOn') }}
|
|
244
|
+
</div>
|
|
245
|
+
<pre
|
|
246
|
+
class="mb-2 max-h-20 overflow-auto whitespace-pre-wrap rounded bg-slate-950/60 p-1.5 text-[11px] text-slate-300"
|
|
247
|
+
>{{ draftTarget.quotedSource }}</pre>
|
|
248
|
+
<UTextarea
|
|
249
|
+
v-model="draftBody"
|
|
250
|
+
data-testid="initiative-plan-comment-body"
|
|
251
|
+
:rows="2"
|
|
252
|
+
autoresize
|
|
253
|
+
size="sm"
|
|
254
|
+
class="w-full"
|
|
255
|
+
:placeholder="t('panels.stepDetail.commentPlaceholder')"
|
|
256
|
+
/>
|
|
257
|
+
<div class="mt-2 flex justify-end gap-2">
|
|
258
|
+
<UButton color="neutral" variant="ghost" size="xs" @click="cancelDraft">
|
|
259
|
+
{{ t('common.cancel') }}
|
|
260
|
+
</UButton>
|
|
261
|
+
<UButton
|
|
262
|
+
color="primary"
|
|
263
|
+
size="xs"
|
|
264
|
+
data-testid="initiative-plan-comment-add"
|
|
265
|
+
:disabled="!draftBody.trim()"
|
|
266
|
+
@click="addDraftComment"
|
|
267
|
+
>
|
|
268
|
+
{{ t('panels.stepDetail.addComment') }}
|
|
269
|
+
</UButton>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<ul v-if="planComments.length" class="mt-3 space-y-2 px-3.5">
|
|
275
|
+
<li
|
|
276
|
+
v-for="(c, idx) in planComments"
|
|
277
|
+
:key="idx"
|
|
278
|
+
data-testid="initiative-plan-comment"
|
|
279
|
+
class="rounded-lg border border-slate-700 bg-slate-900/50 p-2.5"
|
|
280
|
+
>
|
|
281
|
+
<div class="mb-1 flex items-start justify-between gap-2">
|
|
282
|
+
<div class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
283
|
+
{{ t('panels.stepDetail.commentN', { number: idx + 1 }) }}
|
|
284
|
+
</div>
|
|
285
|
+
<button
|
|
286
|
+
class="text-slate-500 transition hover:text-rose-400"
|
|
287
|
+
:title="t('panels.stepDetail.removeComment')"
|
|
288
|
+
@click="removeComment(idx)"
|
|
289
|
+
>
|
|
290
|
+
<UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
|
|
291
|
+
</button>
|
|
292
|
+
</div>
|
|
293
|
+
<pre
|
|
294
|
+
class="mb-1 max-h-16 overflow-auto whitespace-pre-wrap rounded bg-slate-950/50 p-1.5 text-[10px] text-slate-400"
|
|
295
|
+
>{{ c.quotedSource }}</pre>
|
|
296
|
+
<p class="text-[12px] text-slate-200">{{ c.body }}</p>
|
|
297
|
+
</li>
|
|
298
|
+
</ul>
|
|
299
|
+
|
|
300
|
+
<!-- Overall feedback + the two actions. Feedback stays visible rather than hiding behind a
|
|
301
|
+
"request changes" step: with per-block comments in play the human is already composing a
|
|
302
|
+
review, and a hidden field reads as "there is nothing more to say". -->
|
|
303
|
+
<div class="mt-3 px-3.5 pb-3.5">
|
|
304
|
+
<UTextarea
|
|
305
|
+
v-model="feedback"
|
|
306
|
+
data-testid="initiative-plan-feedback"
|
|
307
|
+
:rows="2"
|
|
308
|
+
autoresize
|
|
309
|
+
size="sm"
|
|
310
|
+
class="w-full"
|
|
311
|
+
:placeholder="t('initiative.planReview.feedbackPlaceholder')"
|
|
312
|
+
/>
|
|
313
|
+
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
314
|
+
<UButton
|
|
315
|
+
color="primary"
|
|
316
|
+
size="xs"
|
|
317
|
+
icon="i-lucide-check"
|
|
318
|
+
data-testid="initiative-plan-approve"
|
|
319
|
+
:loading="submitting"
|
|
320
|
+
:disabled="!canExecute"
|
|
321
|
+
:title="disabledTitle"
|
|
322
|
+
@click="approve"
|
|
323
|
+
>
|
|
324
|
+
{{ t('initiative.planReview.approve') }}
|
|
325
|
+
</UButton>
|
|
326
|
+
<UButton
|
|
327
|
+
color="warning"
|
|
328
|
+
variant="soft"
|
|
329
|
+
size="xs"
|
|
330
|
+
icon="i-lucide-rotate-ccw"
|
|
331
|
+
data-testid="initiative-plan-send-back"
|
|
332
|
+
:loading="submitting"
|
|
333
|
+
:disabled="!canRequestChanges || !canExecute"
|
|
334
|
+
:title="
|
|
335
|
+
canExecute && !canRequestChanges
|
|
336
|
+
? t('initiative.planReview.needsFeedback')
|
|
337
|
+
: disabledTitle
|
|
338
|
+
"
|
|
339
|
+
@click="requestChanges"
|
|
340
|
+
>
|
|
341
|
+
{{ t('initiative.planReview.sendBack') }}
|
|
342
|
+
</UButton>
|
|
343
|
+
<!-- Only worth saying where clicking a block does something (a document + the RBAC to
|
|
344
|
+
act on it). -->
|
|
345
|
+
<p v-if="hasOutput && canExecute" class="text-[10px] text-amber-100/60">
|
|
346
|
+
{{ t('initiative.planReview.commentHint') }}
|
|
347
|
+
</p>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</section>
|
|
351
|
+
</template>
|