@cat-factory/app 0.194.0 → 0.195.1

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.
@@ -1,79 +1,76 @@
1
1
  <script setup lang="ts">
2
- // The planner's human gate, reviewed on the PLAN — the tracker window's plan-approval rail.
2
+ // The planner's human gate, reviewed on the PLAN — the surface the tracker window hands its WHOLE
3
+ // body to while a rendered plan is parked.
3
4
  //
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
5
+ // The rail this replaces could approve or send back, but it judged nothing: the plan was a wall of
6
+ // structured sections above it, with no way to navigate a long one and no way to say WHICH part
7
+ // needed changing. So it renders the plan as the document it is — the engine puts a markdown
7
8
  // 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.
9
+ // by the planner's step resolver, the only thing that knows what ingest committed) — and gives it
10
+ // the same three tools the step reader gives the architect's prose: an outline to navigate by,
11
+ // click-to-comment on any block, and overall feedback.
14
12
  //
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'
13
+ // The LAYOUT is the step reader's too, and for the same reasons (`AgentStepDetail.vue`). The first
14
+ // cut of this surface was a card INSIDE the tracker's scrolling column: outline and document split
15
+ // that column's width between them, the document capped at a 20rem window, and the tracker's own
16
+ // goal / phases / policy / logs sections — the very same plan, since the render reads the ingested
17
+ // entity repeated underneath. Reviewers scrolled a letterbox while a second copy of what they
18
+ // were reading sat below it. Now the outline is a sidebar OUTSIDE the document, the document takes
19
+ // the full height of the window, the commands sit in an end-side rail, and the duplicate is gone
20
+ // because the window renders this INSTEAD of the tracker while the gate is parked (the tracker's
21
+ // remaining panels — PR links, curation, checkpoints, follow-ups — are all execution-time state
22
+ // that cannot exist yet at plan time).
23
+ //
24
+ // Everything under the layout is shared with that reader rather than re-implemented: `useStepProse`
25
+ // for the outline/collapse/scroll-spy, `useProseComments` for the anchoring, `InitiativePlanDecision`
26
+ // for the two commands, and the global `.reader-prose` sheet for the presentation — so the surfaces
27
+ // cannot drift.
28
+ import { ref, watch } from 'vue'
21
29
  import type { StepApproval } from '~/types/execution'
22
30
  import { useStepProse } from '~/composables/useStepProse'
23
31
  import { useProseComments } from '~/composables/useProseComments'
32
+ import InitiativePlanDecision from '~/components/initiative/InitiativePlanDecision.vue'
24
33
 
25
34
  const props = defineProps<{
26
- /** The parked gate: its `proposal` is the rendered plan document under review. */
35
+ /** The parked gate under review. */
27
36
  approval: StepApproval
28
37
  /** The run the gate belongs to, for the approve / request-changes commands. */
29
38
  instanceId: string
30
39
  /** Whether the viewer may resolve runs at all (RBAC); false renders the actions disabled. */
31
40
  canExecute: boolean
32
41
  /**
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.
42
+ * The rendered plan under review, resolved by the host through `planReviewDocument` the same
43
+ * value that decided to mount THIS surface rather than the notice, so it is non-empty here.
38
44
  */
39
- outputIsRendered: boolean
45
+ planDocument: string
40
46
  }>()
41
47
 
42
- const execution = useExecutionStore()
43
48
  const { t } = useI18n()
44
49
 
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 })
50
+ // The outline + collapse + scroll-spy, exactly as the step reader resolves them — minus its lead
51
+ // anchor: the reader renders a details card ahead of the prose, while here the run details sit in
52
+ // the sidebar (never scrolled past), and the spy stops at the first anchor it cannot measure.
53
+ const prose = useStepProse(() => props.planDocument, { leadAnchorId: null })
58
54
  const {
59
55
  outline,
60
56
  tocSections,
61
- hasOutput,
62
57
  collapsed,
63
58
  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.
59
+ // The scroll container, bound straight through so the shared scroll-spy and the comment-highlight
60
+ // sync read the same element the template scrolls.
66
61
  scrollEl,
67
62
  sectionEls,
68
63
  toggle,
64
+ setAll,
65
+ allCollapsed,
69
66
  goTo,
70
67
  onScroll,
71
68
  } = prose
72
69
 
73
70
  /**
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.
71
+ * Per-block comment drafts over the plan, anchored to its source lines. Commenting follows the same
72
+ * RBAC gate as the commands: a viewer who cannot resolve the run cannot send the comments anywhere,
73
+ * so offering the composer would only invite work the Send-back button then refuses.
77
74
  */
78
75
  const {
79
76
  comments: planComments,
@@ -86,266 +83,293 @@ const {
86
83
  removeComment,
87
84
  reset: resetComments,
88
85
  } = useProseComments({
89
- output: () => planDocument.value,
86
+ output: () => props.planDocument,
90
87
  root: () => scrollEl.value,
91
88
  enabled: () => props.canExecute,
92
89
  })
93
90
 
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. */
91
+ /** A fresh gate (a re-plan parked again) reviews the new plan clean, from the top. */
101
92
  watch(
102
93
  () => props.approval.id,
103
94
  () => {
104
95
  resetComments()
105
- feedback.value = ''
106
96
  prose.reset()
107
97
  },
108
98
  )
109
99
 
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
- }
100
+ /** Whether the sidebar's run-details stack is expanded (it is, until a reviewer wants the outline). */
101
+ const runDetailsOpen = ref(true)
124
102
 
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
- }
103
+ const { copy } = useCopyToClipboard()
104
+ async function copyPlan() {
105
+ await copy(props.planDocument)
141
106
  }
142
-
143
- const disabledTitle = computed(() => (props.canExecute ? undefined : t('access.noRunExecute')))
144
107
  </script>
145
108
 
146
109
  <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>
110
+ <div class="flex min-h-0 flex-1 flex-col lg:flex-row" data-testid="initiative-plan-review">
111
+ <!-- Navigation column: the outline OUTSIDE the document rather than splitting its width — a
112
+ sidebar of the window, as the step reader's is. Narrower than the reader's (`w-52` against
113
+ its `w-72`) and held back to `lg` rather than its `md`, because this is a THREE-column
114
+ layout: at 768px the document would be left ~240px between the outline and the review rail,
115
+ which reads worse than no outline at all. Below `lg` the whole column goes; the document and
116
+ the commands are what a narrow screen needs.
162
117
 
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 belowthe 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. -->
118
+ Its presence tracks its two contents INDEPENDENTLY rather than the outline alone. Gating the
119
+ run details on `outline.hasToc` would make whether this window still reports its model / run
120
+ id / token spend depend on whether the plan renderer happened to emit a heading a fact
121
+ owned by `renderInitiativePlanForReview`, in another package, with nothing pinning it. The
122
+ step reader keeps the same document-level affordances out of its own `hasToc` guard for that
123
+ reason, in a main-column header this surface does not have. -->
124
+ <aside
125
+ v-if="outline.hasToc || $slots['run-details']"
126
+ class="hidden w-52 shrink-0 flex-col border-e border-slate-800 bg-slate-900/60 lg:flex"
127
+ >
128
+ <div class="flex items-center gap-0.5 border-b border-slate-800 px-3 py-2">
129
+ <span
130
+ v-if="outline.hasToc"
131
+ class="min-w-0 flex-1 truncate text-[11px] font-semibold uppercase tracking-wide text-slate-500"
132
+ >
133
+ {{ t('panels.stepDetail.contents') }}
134
+ </span>
135
+ <span v-else class="flex-1" />
136
+ <!-- Collapse-all tracks the outline: with no headings the only section is the untitled
137
+ preamble, which renders no toggle of its own, so collapsing it would hide the whole
138
+ plan with nothing on screen to bring it back. Copying it does not — that is about the
139
+ document, which exists either way. -->
140
+ <UButton
141
+ v-if="outline.hasToc"
142
+ :icon="allCollapsed ? 'i-lucide-unfold-vertical' : 'i-lucide-fold-vertical'"
143
+ color="neutral"
144
+ variant="ghost"
145
+ size="xs"
146
+ :title="
147
+ allCollapsed ? t('panels.stepDetail.expandAll') : t('panels.stepDetail.collapseAll')
148
+ "
149
+ @click="setAll(!allCollapsed)"
150
+ />
151
+ <UButton
152
+ icon="i-lucide-copy"
153
+ color="neutral"
154
+ variant="ghost"
155
+ size="xs"
156
+ :title="t('panels.stepDetail.copyRawOutput')"
157
+ @click="copyPlan"
158
+ />
159
+ </div>
170
160
  <nav
171
161
  v-if="outline.hasToc"
172
162
  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"
163
+ :aria-label="t('panels.stepDetail.contents')"
164
+ class="flex-1 space-y-0.5 overflow-y-auto px-2 py-2"
174
165
  >
175
166
  <button
176
167
  v-for="s in tocSections"
177
168
  :key="s.id"
178
- class="block w-full truncate rounded px-1.5 py-1 text-start text-[12px] transition"
169
+ class="block w-full truncate rounded-md px-2 py-1 text-start text-[12px] transition"
179
170
  :class="
180
171
  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'
172
+ ? 'bg-amber-500/15 font-medium text-amber-100'
173
+ : 'text-slate-400 hover:bg-slate-800/60 hover:text-slate-200'
183
174
  "
184
- :style="{ paddingLeft: `${(s.depth - outline.minDepth) * 0.7 + 0.4}rem` }"
175
+ :style="{ paddingLeft: `${(s.depth - outline.minDepth) * 0.7 + 0.5}rem` }"
185
176
  :title="s.title"
186
177
  @click="goTo(s.id)"
187
178
  >
188
179
  {{ s.title }}
189
180
  </button>
190
181
  </nav>
191
-
182
+ <!-- Run details (the model, the run id, the token telemetry). Filled by the host, which
183
+ already resolves the bundle through `useResultViewRunMeta`; it keeps its home in a
184
+ sidebar here rather than disappearing for the duration of the review. Open, but
185
+ collapsible: it is a stack of seven labelled fields, and a reviewer navigating a long
186
+ plan should be able to give the outline the whole column. With no outline above it there
187
+ is no column to give back, so it takes the space instead of leaving 55% of it empty. -->
192
188
  <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"
189
+ v-if="$slots['run-details']"
190
+ class="flex flex-col"
191
+ :class="
192
+ outline.hasToc ? 'max-h-[45%] shrink-0 border-t border-slate-800' : 'min-h-0 flex-1'
193
+ "
197
194
  >
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"
195
+ <button
196
+ type="button"
197
+ data-testid="initiative-plan-run-meta-toggle"
198
+ class="flex shrink-0 items-center gap-1.5 px-3 py-2 text-start transition hover:bg-slate-800/40"
199
+ :aria-expanded="runDetailsOpen"
200
+ @click="runDetailsOpen = !runDetailsOpen"
204
201
  >
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"
202
+ <span class="flex-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
203
+ {{ t('panels.stepDetail.details') }}
204
+ </span>
205
+ <UIcon
206
+ :name="runDetailsOpen ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'"
207
+ class="h-3.5 w-3.5 shrink-0 text-slate-500"
231
208
  />
232
- </section>
209
+ </button>
210
+ <div
211
+ v-if="runDetailsOpen"
212
+ data-testid="initiative-plan-run-meta"
213
+ class="min-h-0 flex-1 overflow-y-auto px-3 pb-3"
214
+ >
215
+ <slot name="run-details" />
216
+ </div>
233
217
  </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>
218
+ </aside>
238
219
 
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')"
220
+ <!-- The plan itself, with the full height of the window to be read in. -->
221
+ <div
222
+ ref="scrollEl"
223
+ data-testid="initiative-plan-document"
224
+ class="min-h-0 min-w-0 flex-1 overflow-y-auto px-5 py-4"
225
+ @scroll="onScroll"
226
+ >
227
+ <!-- No `max-w-*` reading measure here: with the outline and the review rail both taking a
228
+ fixed column out of the shell's `5xl`, this one is ~490px wide at every size that renders
229
+ it, so a cap would only ever be dead markup. -->
230
+ <section
231
+ v-for="s in outline.sections"
232
+ :id="s.id"
233
+ :key="s.id"
234
+ :ref="(el) => (sectionEls[s.id] = el as HTMLElement | null)"
235
+ class="scroll-mt-2"
236
+ >
237
+ <button
238
+ v-if="s.depth > 0"
239
+ class="group flex w-full items-center gap-1.5 rounded py-0.5 text-start transition hover:text-white"
240
+ :aria-expanded="!collapsed[s.id]"
241
+ @click="toggle(s.id)"
242
+ >
243
+ <UIcon
244
+ name="i-lucide-chevron-right"
245
+ class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform group-hover:text-slate-300"
246
+ :class="collapsed[s.id] ? '' : 'rotate-90'"
247
+ />
248
+ <span
249
+ class="font-semibold text-slate-100"
250
+ :class="s.depth <= 1 ? 'text-base' : s.depth === 2 ? 'text-sm' : 'text-[13px]'"
251
+ v-html="s.titleHtml"
252
+ />
253
+ </button>
254
+ <!-- `review-mode` carries the click-to-comment affordance, so it tracks the same RBAC
255
+ gate the composer does — a viewer gets the document, not hover targets that lead
256
+ nowhere. -->
257
+ <!-- eslint-disable-next-line vue/no-v-html -->
258
+ <div
259
+ v-show="!collapsed[s.id]"
260
+ class="reader-prose mt-0.5 text-[13px] leading-relaxed text-slate-300"
261
+ :class="[s.depth > 0 ? 'ps-5' : '', canExecute ? 'review-mode' : '']"
262
+ @click="onProseClick"
263
+ v-html="s.bodyHtml"
256
264
  />
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>
265
+ </section>
272
266
  </div>
273
267
 
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 }) }}
268
+ <!-- Review rail: what the human is being asked, the anchored comments so far, and the two
269
+ commands — the step reader's end-side rail. Below `lg` it drops under the document, capped
270
+ so a long comment list can't crowd the plan off the screen. -->
271
+ <aside
272
+ :aria-label="t('initiative.planReview.title')"
273
+ class="flex max-h-[55%] w-full shrink-0 flex-col border-t border-slate-800 bg-slate-900/60 lg:max-h-none lg:w-72 lg:border-s lg:border-t-0"
274
+ >
275
+ <div class="border-b border-slate-800 px-4 py-3">
276
+ <!-- A HEADING, not a styled div: this rail is what the window is now for, so the surface
277
+ that asks the human for a decision has to be reachable as one. -->
278
+ <h3
279
+ class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-amber-400"
280
+ >
281
+ <UIcon name="i-lucide-clipboard-check" class="h-3.5 w-3.5 shrink-0" />
282
+ {{ t('initiative.planReview.title') }}
283
+ </h3>
284
+ <p class="mt-1 text-[12px] leading-relaxed text-slate-400">
285
+ {{ t('initiative.planReview.body') }}
286
+ </p>
287
+ </div>
288
+
289
+ <div class="flex-1 space-y-3 overflow-y-auto overscroll-contain px-4 py-3">
290
+ <!-- Composer for the block just clicked. -->
291
+ <div
292
+ v-if="draftTarget"
293
+ data-testid="initiative-plan-composer"
294
+ class="rounded-lg border border-indigo-500/40 bg-indigo-500/5 p-2.5"
295
+ >
296
+ <div class="mb-1 text-[10px] uppercase tracking-wide text-indigo-300">
297
+ {{ t('panels.stepDetail.commentingOn') }}
298
+ </div>
299
+ <pre
300
+ class="mb-2 max-h-20 overflow-auto whitespace-pre-wrap rounded bg-slate-950/60 p-1.5 text-[11px] text-slate-300"
301
+ >{{ draftTarget.quotedSource }}</pre>
302
+ <UTextarea
303
+ v-model="draftBody"
304
+ data-testid="initiative-plan-comment-body"
305
+ :rows="2"
306
+ autoresize
307
+ size="sm"
308
+ class="w-full"
309
+ :placeholder="t('panels.stepDetail.commentPlaceholder')"
310
+ />
311
+ <div class="mt-2 flex justify-end gap-2">
312
+ <UButton color="neutral" variant="ghost" size="xs" @click="cancelDraft">
313
+ {{ t('common.cancel') }}
314
+ </UButton>
315
+ <UButton
316
+ color="primary"
317
+ size="xs"
318
+ data-testid="initiative-plan-comment-add"
319
+ :disabled="!draftBody.trim()"
320
+ @click="addDraftComment"
321
+ >
322
+ {{ t('panels.stepDetail.addComment') }}
323
+ </UButton>
284
324
  </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
325
  </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
326
 
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"
327
+ <!-- The anchored comments so far. -->
328
+ <div
329
+ v-for="(c, idx) in planComments"
330
+ :key="idx"
331
+ data-testid="initiative-plan-comment"
332
+ class="rounded-lg border border-slate-800 bg-slate-900/50 p-2.5"
323
333
  >
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"
334
+ <div class="mb-1 flex items-start justify-between gap-2">
335
+ <div class="text-[10px] uppercase tracking-wide text-slate-500">
336
+ {{ t('panels.stepDetail.commentN', { number: idx + 1 }) }}
337
+ </div>
338
+ <button
339
+ class="text-slate-500 transition hover:text-rose-400"
340
+ :title="t('panels.stepDetail.removeComment')"
341
+ @click="removeComment(idx)"
342
+ >
343
+ <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
344
+ </button>
345
+ </div>
346
+ <pre
347
+ class="mb-1 max-h-16 overflow-auto whitespace-pre-wrap rounded bg-slate-950/50 p-1.5 text-[10px] text-slate-400"
348
+ >{{ c.quotedSource }}</pre>
349
+ <p class="text-[12px] text-slate-200">{{ c.body }}</p>
350
+ </div>
351
+
352
+ <!-- Only worth saying where clicking a block does something (the RBAC to act on it) and
353
+ where nothing has been said yet. -->
354
+ <p
355
+ v-if="canExecute && !draftTarget && !planComments.length"
356
+ class="text-[11px] leading-relaxed text-slate-400"
340
357
  >
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
358
  {{ t('initiative.planReview.commentHint') }}
347
359
  </p>
348
360
  </div>
349
- </div>
350
- </section>
361
+
362
+ <!-- Overall feedback + the two commands. Feedback stays visible rather than hiding behind a
363
+ "request changes" step: with per-block comments in play the human is already composing a
364
+ review, and a hidden field reads as "there is nothing more to say". -->
365
+ <InitiativePlanDecision
366
+ class="border-t border-slate-800 px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]"
367
+ :approval-id="approval.id"
368
+ :instance-id="instanceId"
369
+ :can-execute="canExecute"
370
+ :comments="wireComments"
371
+ @sent="resetComments"
372
+ />
373
+ </aside>
374
+ </div>
351
375
  </template>