@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.
Files changed (32) hide show
  1. package/README.md +4 -1
  2. package/app/assets/css/main.css +2 -0
  3. package/app/assets/css/prose.css +135 -0
  4. package/app/components/board/AddTaskModal.vue +31 -1
  5. package/app/components/brainstorm/BrainstormWindow.vue +4 -37
  6. package/app/components/clarity/ClarityReviewWindow.vue +4 -37
  7. package/app/components/initiative/InitiativePlanReview.vue +351 -0
  8. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -126
  9. package/app/components/panels/AgentStepDetail.vue +19 -122
  10. package/app/components/panels/inspector/TaskReviewTarget.vue +69 -0
  11. package/app/components/requirements/RequirementsReviewWindow.vue +4 -37
  12. package/app/composables/useProseComments.ts +126 -0
  13. package/app/composables/useStepApproval.ts +29 -85
  14. package/app/composables/useStepProse.spec.ts +67 -0
  15. package/app/composables/useStepProse.ts +22 -10
  16. package/app/modular/nav-contributions.spec.ts +3 -13
  17. package/app/modular/panels/inspector.logic.spec.ts +7 -0
  18. package/app/modular/panels/inspector.logic.ts +5 -0
  19. package/app/modular/panels/inspector.ts +2 -0
  20. package/app/stores/execution.ts +9 -0
  21. package/app/utils/initiative.spec.ts +24 -0
  22. package/i18n/locales/de.json +14 -3
  23. package/i18n/locales/en.json +14 -3
  24. package/i18n/locales/es.json +14 -3
  25. package/i18n/locales/fr.json +14 -3
  26. package/i18n/locales/he.json +14 -3
  27. package/i18n/locales/it.json +14 -3
  28. package/i18n/locales/ja.json +14 -3
  29. package/i18n/locales/pl.json +14 -3
  30. package/i18n/locales/tr.json +14 -3
  31. package/i18n/locales/uk.json +14 -3
  32. 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
@@ -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
- /* Minimal CommonMark styling for the converged-direction reader (mirrors the prose
614
- review window's reader-prose). */
615
- .reader-prose :deep(p) {
616
- margin: 0.4rem 0;
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
- /* Minimal CommonMark styling for the clarified bug-report reader (mirrors the
662
- prose review window's reader-prose). */
663
- .reader-prose :deep(p) {
664
- margin: 0.4rem 0;
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>