@cat-factory/app 0.195.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.
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ // The plan gate's DECISION half: the overall-feedback field plus the two commands that resolve the
3
+ // park — accept the plan, or send it back to the planner.
4
+ //
5
+ // It is its own component because the gate has two surfaces: the full document review
6
+ // (`InitiativePlanReview`, which owns the tracker window while a rendered plan is parked) and the
7
+ // compact notice a gate with no rendered plan falls back to (`InitiativePlanNotice`). Both must
8
+ // word the commands identically, gate them on the same RBAC fact, and refuse an empty send-back
9
+ // the same way — so the state machine and its markup live here once rather than in each.
10
+ //
11
+ // Deliberately NOT offered: "approve with corrections". The plan was ingested into the
12
+ // `initiatives` entity before this gate was raised, so the document is a VIEW of committed state —
13
+ // an edit typed over it would reach nothing, and the engine refuses it outright
14
+ // (`outputIsRendered` → 422). Requesting changes is the route for a correction, which is why an
15
+ // anchored comment is worth having: it quotes the planner's own text back to it on the re-plan.
16
+ import { computed, ref, watch } from 'vue'
17
+ import type { RequestStepChangesInput } from '@cat-factory/contracts'
18
+
19
+ const props = defineProps<{
20
+ /** The parked gate being resolved. */
21
+ approvalId: string
22
+ /** The run the gate belongs to, for the approve / request-changes commands. */
23
+ instanceId: string
24
+ /** Whether the viewer may resolve runs at all (RBAC); false renders the actions disabled. */
25
+ canExecute: boolean
26
+ /**
27
+ * The anchored per-block comments to send with a send-back, from the surface that offers
28
+ * commenting. Absent on the notice surface, which has no document to anchor to.
29
+ */
30
+ comments?: RequestStepChangesInput['comments']
31
+ }>()
32
+
33
+ /** A send-back succeeded: the surface drops its anchored drafts (the feedback is cleared here). */
34
+ const emit = defineEmits<{ sent: [] }>()
35
+
36
+ const execution = useExecutionStore()
37
+ const { t } = useI18n()
38
+
39
+ const feedback = ref('')
40
+ const submitting = ref(false)
41
+
42
+ /** Changes can only be requested with something to act on — an empty send would re-plan blind. */
43
+ const canRequestChanges = computed(
44
+ () => !!feedback.value.trim() || (props.comments?.length ?? 0) > 0,
45
+ )
46
+
47
+ /** A fresh gate (a re-plan parked again) reviews the new plan clean, drafts dropped. */
48
+ watch(
49
+ () => props.approvalId,
50
+ () => {
51
+ feedback.value = ''
52
+ },
53
+ )
54
+
55
+ /**
56
+ * Accept the plan: the run advances to the committer, which persists it and arms the execution
57
+ * loop. The window stays open — the review disappears with the approval (live) and the tracker it
58
+ * gives the window back to is where the plan then executes.
59
+ */
60
+ async function approve() {
61
+ if (submitting.value || !props.canExecute) return
62
+ submitting.value = true
63
+ try {
64
+ await execution.approveStep(props.instanceId, props.approvalId)
65
+ } finally {
66
+ submitting.value = false
67
+ }
68
+ }
69
+
70
+ /** Send the plan back: the planner re-plans from the feedback + the anchored comments. */
71
+ async function requestChanges() {
72
+ if (submitting.value || !canRequestChanges.value || !props.canExecute) return
73
+ submitting.value = true
74
+ try {
75
+ const ok = await execution.requestStepChanges(props.instanceId, props.approvalId, {
76
+ feedback: feedback.value.trim() || undefined,
77
+ comments: props.comments,
78
+ })
79
+ if (ok) {
80
+ feedback.value = ''
81
+ emit('sent')
82
+ }
83
+ } finally {
84
+ submitting.value = false
85
+ }
86
+ }
87
+
88
+ const disabledTitle = computed(() => (props.canExecute ? undefined : t('access.noRunExecute')))
89
+ </script>
90
+
91
+ <template>
92
+ <div>
93
+ <UTextarea
94
+ v-model="feedback"
95
+ data-testid="initiative-plan-feedback"
96
+ :rows="2"
97
+ autoresize
98
+ size="sm"
99
+ class="w-full"
100
+ :placeholder="t('initiative.planReview.feedbackPlaceholder')"
101
+ />
102
+ <div class="mt-2 flex flex-wrap items-center gap-2">
103
+ <UButton
104
+ color="primary"
105
+ size="xs"
106
+ icon="i-lucide-check"
107
+ data-testid="initiative-plan-approve"
108
+ :loading="submitting"
109
+ :disabled="!canExecute"
110
+ :title="disabledTitle"
111
+ @click="approve"
112
+ >
113
+ {{ t('initiative.planReview.approve') }}
114
+ </UButton>
115
+ <UButton
116
+ color="warning"
117
+ variant="soft"
118
+ size="xs"
119
+ icon="i-lucide-rotate-ccw"
120
+ data-testid="initiative-plan-send-back"
121
+ :loading="submitting"
122
+ :disabled="!canRequestChanges || !canExecute"
123
+ :title="
124
+ canExecute && !canRequestChanges
125
+ ? t('initiative.planReview.needsFeedback')
126
+ : disabledTitle
127
+ "
128
+ @click="requestChanges"
129
+ >
130
+ {{ t('initiative.planReview.sendBack') }}
131
+ </UButton>
132
+ </div>
133
+ </div>
134
+ </template>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ // The plan gate with NO document to review: the compact rail that sits above the tracker's own
3
+ // sections, states that there is nothing to navigate, and still resolves the park.
4
+ //
5
+ // It is NOT a legacy shim for gates parked before the engine rendered plans. `outputIsRendered` is a
6
+ // fact about the STEP, and a plan gate can park without it today: the planner's post-completion
7
+ // resolver authors the rendering only once it has INGESTED the plan, so a run that reaches the gate
8
+ // without an ingest parks on the planner's transcript summary instead — a perfectly non-empty string
9
+ // that `planReviewDocument` correctly reads as no document (dressing one sentence up under a table
10
+ // of contents is the failure the whole review surface exists to end). Without this surface such a
11
+ // gate would have no resolving surface at all, which is the exact bug the plan-review e2e spec was
12
+ // written for.
13
+ //
14
+ // So it is deliberately NOT a takeover: unlike `InitiativePlanReview`, which replaces the tracker
15
+ // because the document repeats it, this one wants the tracker underneath — there the sections ARE
16
+ // the only rendering of the plan there is. Which is why it has to be TOLD whether they are actually
17
+ // on screen: it renders above the entity branch, because a gate can be parked while the entity is
18
+ // still loading, and pointing a reviewer at sections the window is not showing is worse than saying
19
+ // nothing.
20
+ import type { StepApproval } from '~/types/execution'
21
+ import InitiativePlanDecision from '~/components/initiative/InitiativePlanDecision.vue'
22
+
23
+ defineProps<{
24
+ /** The parked gate. */
25
+ approval: StepApproval
26
+ /** The run the gate belongs to, for the approve / request-changes commands. */
27
+ instanceId: string
28
+ /** Whether the viewer may resolve runs at all (RBAC); false renders the actions disabled. */
29
+ canExecute: boolean
30
+ /**
31
+ * Whether the tracker's own goal / phases / policy sections are rendered below this notice — i.e.
32
+ * whether the initiative entity has loaded. Only then may the notice point at them as the plan.
33
+ */
34
+ hasSections: boolean
35
+ }>()
36
+
37
+ const { t } = useI18n()
38
+ </script>
39
+
40
+ <template>
41
+ <section
42
+ class="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3.5"
43
+ data-testid="initiative-plan-notice"
44
+ >
45
+ <header class="flex items-start gap-2.5">
46
+ <UIcon name="i-lucide-clipboard-check" class="mt-0.5 h-4 w-4 shrink-0 text-amber-300" />
47
+ <div class="min-w-0 flex-1">
48
+ <h3 class="text-[13px] font-semibold text-amber-200">
49
+ {{ t('initiative.planReview.title') }}
50
+ </h3>
51
+ <p class="mt-0.5 text-[12px] leading-relaxed text-amber-100/80">
52
+ {{ t('initiative.planReview.body') }}
53
+ </p>
54
+ <p class="mt-2 text-[12px] leading-relaxed text-amber-100/70">
55
+ {{ t('initiative.planReview.noDocument') }}
56
+ <template v-if="hasSections">
57
+ {{ t('initiative.planReview.noDocumentSections') }}
58
+ </template>
59
+ </p>
60
+ </div>
61
+ </header>
62
+ <InitiativePlanDecision
63
+ class="mt-3"
64
+ :approval-id="approval.id"
65
+ :instance-id="instanceId"
66
+ :can-execute="canExecute"
67
+ />
68
+ </section>
69
+ </template>
@@ -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>
@@ -23,10 +23,12 @@ import {
23
23
  INITIATIVE_STATUS_LABEL_KEYS,
24
24
  initiativeProgress,
25
25
  pendingCheckpointPhase,
26
+ planReviewDocument,
26
27
  } from '~/utils/initiative'
27
28
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
28
29
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
29
30
  import InitiativePlanReview from '~/components/initiative/InitiativePlanReview.vue'
31
+ import InitiativePlanNotice from '~/components/initiative/InitiativePlanNotice.vue'
30
32
 
31
33
  const board = useBoardStore()
32
34
  const initiatives = useInitiativesStore()
@@ -61,6 +63,24 @@ const {
61
63
  stepIndex: () => stepIndex.value,
62
64
  })
63
65
 
66
+ /**
67
+ * The `StepRunMeta` prop bundle, or null when no step speaks for this window. Bound as one object
68
+ * because it has two homes — the tracker's end-side column, and the plan review's sidebar while the
69
+ * review owns the window — and the run details must read identically in both.
70
+ */
71
+ const runMeta = computed(() =>
72
+ metaStep.value
73
+ ? {
74
+ step: metaStep.value,
75
+ instanceId: runId.value,
76
+ stepNumber: position.value,
77
+ totalSteps: totalSteps.value,
78
+ runFailed: runFailed.value,
79
+ failureAt: failureAt.value,
80
+ }
81
+ : null,
82
+ )
83
+
64
84
  const phases = computed(() => initiative.value?.phases ?? [])
65
85
  function itemsOf(phaseId: string): InitiativeItem[] {
66
86
  return (initiative.value?.items ?? []).filter((i) => i.phaseId === phaseId)
@@ -103,9 +123,19 @@ async function checkpointControl(action: 'resume' | 'cancel') {
103
123
  // ---- Plan review: the planner step's human gate, resolved right here -----------------------
104
124
  // Derived from the BLOCK (via the shared planning composable), not from this window's own
105
125
  // `stepIndex`: the card / inspector open the tracker with no step, and that is the entry point a
106
- // human parked on the gate actually uses. So the rail appears on every route into the window.
126
+ // human parked on the gate actually uses. So the review appears on every route into the window.
107
127
  const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
108
128
 
129
+ /**
130
+ * The plan document the parked gate offers, or `''` — which is also the layout decision. A rendered
131
+ * plan is a REPLACEMENT for the tracker body rather than a card above it: the render reads the
132
+ * ingested entity, so the sections below would be a second copy of what the reviewer is reading,
133
+ * and everything the tracker adds on top (PR links, item curation, checkpoints, follow-ups) is
134
+ * execution-time state that cannot exist until the plan is committed. With no document, the tracker
135
+ * body IS the plan, so the gate takes the compact notice above it instead.
136
+ */
137
+ const planDocument = computed(() => planReviewDocument(planApproval.value))
138
+
109
139
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
110
140
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
111
141
  const axes = [
@@ -236,30 +266,54 @@ async function savePolicy() {
236
266
  </UBadge>
237
267
  </template>
238
268
 
239
- <div class="flex min-h-0 flex-1">
269
+ <!-- The planner's human gate, with the plan rendered as a document. This window is where the
270
+ park ROUTES (the planner's archetype declares this result view), so it is the only surface
271
+ that can resolve it — and while it is parked the review OWNS the window: an outline sidebar,
272
+ the plan at full height, per-block commenting and the commands in an end-side rail, the same
273
+ tools and the same shape the step reader gives the architect's prose. The tracker body it
274
+ replaces would only repeat the plan (see `planDocument`). -->
275
+ <InitiativePlanReview
276
+ v-if="planApproval && planDocument"
277
+ :approval="planApproval.approval"
278
+ :instance-id="planApproval.instanceId"
279
+ :can-execute="access.canExecuteRuns.value"
280
+ :plan-document="planDocument"
281
+ >
282
+ <template v-if="runMeta" #run-details>
283
+ <StepRunMeta v-bind="runMeta" />
284
+ </template>
285
+ </InitiativePlanReview>
286
+
287
+ <div v-else class="flex min-h-0 flex-1">
240
288
  <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
241
- <!-- No entity yet (module unwired / still creating) -->
289
+ <!-- A parked gate whose step rendered no plan: the commands, plus a notice pointing at the
290
+ sections below — which in that case are the only rendering of the plan there is.
291
+ Deliberately OUTSIDE the entity branch: the gate lives on the RUN, so it is parked
292
+ before `initiatives.load()` has resolved (and stays parked if it fails), and a window
293
+ that answered such a gate with the empty state alone would leave it unresolvable from
294
+ the UI. `hasSections` is what keeps the notice honest about whether the sections it
295
+ points at are actually rendered underneath. -->
296
+ <InitiativePlanNotice
297
+ v-if="planApproval"
298
+ :approval="planApproval.approval"
299
+ :instance-id="planApproval.instanceId"
300
+ :can-execute="access.canExecuteRuns.value"
301
+ :has-sections="!!initiative"
302
+ />
303
+
304
+ <!-- No entity yet (module unwired / still creating). Centred in the column when it is the
305
+ only thing in it; merely inset when the notice above it means `h-full` would overflow
306
+ the scroller by the notice's own height. -->
242
307
  <div
243
308
  v-if="!initiative"
244
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
309
+ class="flex flex-col items-center justify-center gap-2 text-center text-slate-400"
310
+ :class="planApproval ? 'py-16' : 'h-full'"
245
311
  >
246
312
  <UIcon name="i-lucide-milestone" class="h-8 w-8 opacity-40" />
247
313
  <p class="text-sm">{{ t('initiative.tracker.empty') }}</p>
248
314
  </div>
249
315
 
250
316
  <template v-else>
251
- <!-- The planner's human gate. This window is where the park ROUTES (the planner's
252
- archetype declares this result view), so it is the only surface that can resolve
253
- it — and the plan it judges is rendered as a navigable document with per-block
254
- commenting, the same tools the step reader gives the architect's prose. -->
255
- <InitiativePlanReview
256
- v-if="planApproval"
257
- :approval="planApproval.approval"
258
- :instance-id="planApproval.instanceId"
259
- :can-execute="access.canExecuteRuns.value"
260
- :output-is-rendered="planApproval.outputIsRendered"
261
- />
262
-
263
317
  <!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
264
318
  review before the next phase spawns. Read the phase's artifacts/PRs below,
265
319
  then resume (continue) or cancel (stop) the initiative right here. -->
@@ -646,18 +700,11 @@ async function savePolicy() {
646
700
  through `useResultViewRunMeta`, so it is present on the card / inspector entry point
647
701
  too — where this window carries no step index of its own. -->
648
702
  <aside
649
- v-if="metaStep"
703
+ v-if="runMeta"
650
704
  data-testid="initiative-tracker-run-meta"
651
705
  class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
652
706
  >
653
- <StepRunMeta
654
- :step="metaStep"
655
- :instance-id="runId"
656
- :step-number="position"
657
- :total-steps="totalSteps"
658
- :run-failed="runFailed"
659
- :failure-at="failureAt"
660
- />
707
+ <StepRunMeta v-bind="runMeta" />
661
708
  </aside>
662
709
  </div>
663
710
  </ResultWindowShell>
@@ -9,6 +9,7 @@ import {
9
9
  isPendingQuestion,
10
10
  orderInterviewQuestions,
11
11
  pendingCheckpointPhase,
12
+ planReviewDocument,
12
13
  selectPlanApproval,
13
14
  } from './initiative'
14
15
 
@@ -205,6 +206,48 @@ describe('selectPlanApproval', () => {
205
206
  })
206
207
  })
207
208
 
209
+ // Which shape the plan gate takes in the tracker window: a document review that OWNS the window, or
210
+ // the compact notice above the tracker's own sections. Both the window's layout and the review
211
+ // surface read this one value, so these pin the cases where "there is a plan to read" is not the
212
+ // same as "the proposal is non-empty".
213
+
214
+ describe('planReviewDocument', () => {
215
+ const gate = (proposal: string | null | undefined, outputIsRendered: boolean) => ({
216
+ approval: { proposal },
217
+ outputIsRendered,
218
+ })
219
+
220
+ it('is the proposal when the step says it IS the plan rendering', () => {
221
+ expect(planReviewDocument(gate('# Initiative plan\n\n## Goal', true))).toBe(
222
+ '# Initiative plan\n\n## Goal',
223
+ )
224
+ })
225
+
226
+ it('returns the proposal verbatim, so comment anchors stay on the lines they quote', () => {
227
+ // Anchoring is by SOURCE LINE, so trimming a leading newline would shift every anchor up one.
228
+ expect(planReviewDocument(gate('\n# Initiative plan\n', true))).toBe('\n# Initiative plan\n')
229
+ })
230
+
231
+ it('reads an un-rendered proposal as no document, however substantial it looks', () => {
232
+ // The planner's transcript summary: a perfectly non-empty string that is not the plan. Showing
233
+ // it under a table of contents is the failure the rendered review exists to end.
234
+ expect(
235
+ planReviewDocument(gate('I drafted a three-phase plan and stopped for review.', false)),
236
+ ).toBe('')
237
+ })
238
+
239
+ it('reads a rendered but blank proposal as no document', () => {
240
+ expect(planReviewDocument(gate(' \n ', true))).toBe('')
241
+ expect(planReviewDocument(gate(null, true))).toBe('')
242
+ expect(planReviewDocument(gate(undefined, true))).toBe('')
243
+ })
244
+
245
+ it('has no document when nothing is parked', () => {
246
+ expect(planReviewDocument(null)).toBe('')
247
+ expect(planReviewDocument(undefined)).toBe('')
248
+ })
249
+ })
250
+
208
251
  /**
209
252
  * These tables are the reason the initiative card and the inspector word one park identically,
210
253
  * and they are exactly the shape both i18n drift guards are blind to: the typed-key check and
@@ -103,6 +103,29 @@ export function selectPlanApproval<A extends { agentKind: string }>(
103
103
  return approvals.find((a) => resultViewOf(a.agentKind) !== INTERVIEW_GATE_RESULT_VIEW)
104
104
  }
105
105
 
106
+ /**
107
+ * The plan DOCUMENT a parked gate offers for review — its proposal, but only once the step says
108
+ * that proposal IS the plan rendering (`outputIsRendered`); `''` otherwise.
109
+ *
110
+ * A step that rendered nothing parks on the planner's transcript SUMMARY, which is a perfectly
111
+ * non-empty string, so an emptiness check alone would present one sentence under a table of
112
+ * contents as though it were the plan. `''` is what routes such a gate to the compact notice
113
+ * instead — and the SAME value decides the tracker window's layout (a document review takes the
114
+ * whole window; a notice sits above the tracker's own sections), so the surface and its host can
115
+ * never disagree about which shape is on screen.
116
+ *
117
+ * Rendered-but-blank counts as no document. The proposal comes back VERBATIM, never trimmed: the
118
+ * review anchors comments to source LINE numbers, so dropping a leading newline would shift every
119
+ * anchor off the block it quotes.
120
+ */
121
+ export function planReviewDocument(
122
+ gate: { approval: { proposal?: string | null }; outputIsRendered: boolean } | null | undefined,
123
+ ): string {
124
+ if (!gate?.outputIsRendered) return ''
125
+ const proposal = gate.approval.proposal ?? ''
126
+ return proposal.trim() ? proposal : ''
127
+ }
128
+
106
129
  /** Follow-up triage status → i18n label key. Exhaustive so a new status fails the build. */
107
130
  export const INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS: Record<InitiativeFollowUp['status'], string> = {
108
131
  open: 'initiative.followUpStatus.open',
@@ -4426,11 +4426,12 @@
4426
4426
  },
4427
4427
  "planReview": {
4428
4428
  "title": "Dieser Plan wartet auf dich",
4429
- "body": "Der Planner hat die Phasen und Aufgaben unten entworfen. Gib sie frei, um den Plan zu committen und die Arbeit zu starten, oder schicke den Plan mit deinen Änderungswünschen zurück.",
4429
+ "body": "Der Planner hat diese Phasen und Aufgaben entworfen. Gib sie frei, um den Plan zu committen und die Arbeit zu starten, oder schicke den Plan mit deinen Änderungswünschen zurück.",
4430
4430
  "approve": "Plan freigeben",
4431
4431
  "feedbackPlaceholder": "Was soll der Planner ändern? Umfang, Reihenfolge der Phasen, fehlende Arbeit, eine Aufgabe, die woanders hingehört …",
4432
4432
  "sendBack": "An den Planner zurückschicken",
4433
- "noDocument": "Dieser Plan entstand, bevor die Plattform Pläne zur Prüfung gerendert hat — es gibt kein Dokument zum Navigieren; die Abschnitte unten sind der Plan.",
4433
+ "noDocument": "Dieser Planungsschritt hat kein Plandokument zur Prüfung hinterlassen, es gibt hier also nichts zu navigieren.",
4434
+ "noDocumentSections": "Der Plan sind die Abschnitte unten.",
4434
4435
  "needsFeedback": "Füge zuerst einen Kommentar oder eine Rückmeldung hinzu — der Planer plant damit neu.",
4435
4436
  "commentHint": "Klicke auf einen Teil des Plans, um ihn zu kommentieren."
4436
4437
  },
@@ -5645,11 +5645,12 @@
5645
5645
  },
5646
5646
  "planReview": {
5647
5647
  "title": "This plan is waiting for you",
5648
- "body": "The planner drafted the phases and items below. Approve them to commit the plan and start the work, or send the plan back with what to change.",
5648
+ "body": "The planner drafted these phases and items. Approve them to commit the plan and start the work, or send the plan back with what to change.",
5649
5649
  "approve": "Approve plan",
5650
5650
  "feedbackPlaceholder": "What should the planner change? Scope, phase order, missing work, an item that belongs elsewhere…",
5651
5651
  "sendBack": "Send back to the planner",
5652
- "noDocument": "This plan was drafted before the platform rendered plans for review, so there is no document to navigate — the sections below are the plan.",
5652
+ "noDocument": "This planning step left no plan document to review, so there is nothing to navigate here.",
5653
+ "noDocumentSections": "The sections below are the plan.",
5653
5654
  "needsFeedback": "Add a comment or some feedback first — the planner re-plans from it.",
5654
5655
  "commentHint": "Click any part of the plan to comment on it."
5655
5656
  },
@@ -5453,11 +5453,12 @@
5453
5453
  },
5454
5454
  "planReview": {
5455
5455
  "title": "Este plan te está esperando",
5456
- "body": "El planificador redactó las fases y los elementos de abajo. Apruébalos para confirmar el plan y empezar el trabajo, o devuelve el plan indicando qué cambiar.",
5456
+ "body": "El planificador redactó estas fases y estos elementos. Apruébalos para confirmar el plan y empezar el trabajo, o devuelve el plan indicando qué cambiar.",
5457
5457
  "approve": "Aprobar plan",
5458
5458
  "feedbackPlaceholder": "¿Qué debería cambiar el planificador? Alcance, orden de las fases, trabajo que falta, un elemento que va en otro sitio…",
5459
5459
  "sendBack": "Devolver al planificador",
5460
- "noDocument": "Este plan se redactó antes de que la plataforma generara planes para revisión, así que no hay documento que recorrer: las secciones de abajo son el plan.",
5460
+ "noDocument": "Este paso de planificación no dejó ningún documento del plan para revisar, así que aquí no hay nada que recorrer.",
5461
+ "noDocumentSections": "Las secciones de abajo son el plan.",
5461
5462
  "needsFeedback": "Añade primero un comentario o algún comentario general: el planificador replanifica a partir de ello.",
5462
5463
  "commentHint": "Haz clic en cualquier parte del plan para comentarla."
5463
5464
  },
@@ -5453,11 +5453,12 @@
5453
5453
  },
5454
5454
  "planReview": {
5455
5455
  "title": "Ce plan vous attend",
5456
- "body": "Le planificateur a rédigé les phases et les éléments ci-dessous. Approuvez-les pour valider le plan et lancer le travail, ou renvoyez le plan en indiquant ce qu'il faut changer.",
5456
+ "body": "Le planificateur a rédigé ces phases et ces éléments. Approuvez-les pour valider le plan et lancer le travail, ou renvoyez le plan en indiquant ce qu'il faut changer.",
5457
5457
  "approve": "Approuver le plan",
5458
5458
  "feedbackPlaceholder": "Que doit changer le planificateur ? Périmètre, ordre des phases, travail manquant, un élément qui a sa place ailleurs…",
5459
5459
  "sendBack": "Renvoyer au planificateur",
5460
- "noDocument": "Ce plan a été rédigé avant que la plateforme ne produise des plans à relire ; il n'y a donc pas de document à parcourir — les sections ci-dessous sont le plan.",
5460
+ "noDocument": "Cette étape de planification n'a laissé aucun document de plan à relire, il n'y a donc rien à parcourir ici.",
5461
+ "noDocumentSections": "Les sections ci-dessous sont le plan.",
5461
5462
  "needsFeedback": "Ajoutez d'abord un commentaire ou un retour : le planificateur s'en sert pour replanifier.",
5462
5463
  "commentHint": "Cliquez sur une partie du plan pour la commenter."
5463
5464
  },
@@ -5464,11 +5464,12 @@
5464
5464
  },
5465
5465
  "planReview": {
5466
5466
  "title": "התוכנית הזו ממתינה לך",
5467
- "body": "המתכנן ניסח את השלבים והפריטים שלמטה. אשרו אותם כדי לשמור את התוכנית ולהתחיל בעבודה, או החזירו את התוכנית עם מה שצריך לשנות.",
5467
+ "body": "המתכנן ניסח את השלבים והפריטים האלה. אשרו אותם כדי לשמור את התוכנית ולהתחיל בעבודה, או החזירו את התוכנית עם מה שצריך לשנות.",
5468
5468
  "approve": "אישור התוכנית",
5469
5469
  "feedbackPlaceholder": "מה המתכנן צריך לשנות? היקף, סדר השלבים, עבודה חסרה, פריט ששייך למקום אחר…",
5470
5470
  "sendBack": "החזרה למתכנן",
5471
- "noDocument": "התוכנית הזו נוסחה לפני שהפלטפורמה יצרה תוכניות לבדיקה, ולכן אין מסמך לנווט בו — הסעיפים שלמטה הם התוכנית.",
5471
+ "noDocument": "שלב התכנון הזה לא השאיר מסמך תוכנית לבדיקה, ולכן אין כאן במה לנווט.",
5472
+ "noDocumentSections": "הסעיפים שלמטה הם התוכנית.",
5472
5473
  "needsFeedback": "הוסיפו תחילה הערה או משוב — המתכנן מתכנן מחדש על סמך זה.",
5473
5474
  "commentHint": "לחצו על כל חלק בתוכנית כדי להעיר עליו."
5474
5475
  },
@@ -4426,11 +4426,12 @@
4426
4426
  },
4427
4427
  "planReview": {
4428
4428
  "title": "Questo piano ti sta aspettando",
4429
- "body": "Il planner ha redatto le fasi e gli elementi qui sotto. Approvali per confermare il piano e avviare il lavoro, oppure rimanda indietro il piano indicando cosa cambiare.",
4429
+ "body": "Il planner ha redatto queste fasi e questi elementi. Approvali per confermare il piano e avviare il lavoro, oppure rimanda indietro il piano indicando cosa cambiare.",
4430
4430
  "approve": "Approva il piano",
4431
4431
  "feedbackPlaceholder": "Cosa deve cambiare il planner? Ambito, ordine delle fasi, lavoro mancante, un elemento che sta altrove…",
4432
4432
  "sendBack": "Rimanda al planner",
4433
- "noDocument": "Questo piano è stato redatto prima che la piattaforma generasse piani da revisionare, quindi non c'è un documento da percorrere: le sezioni qui sotto sono il piano.",
4433
+ "noDocument": "Questo passaggio di pianificazione non ha lasciato alcun documento del piano da revisionare, quindi qui non c'è nulla da percorrere.",
4434
+ "noDocumentSections": "Le sezioni qui sotto sono il piano.",
4434
4435
  "needsFeedback": "Aggiungi prima un commento o un riscontro: il pianificatore ripianifica a partire da questo.",
4435
4436
  "commentHint": "Fai clic su una parte del piano per commentarla."
4436
4437
  },
@@ -5465,11 +5465,12 @@
5465
5465
  },
5466
5466
  "planReview": {
5467
5467
  "title": "この計画が承認を待っています",
5468
- "body": "プランナーが以下のフェーズと項目を起草しました。承認すると計画がコミットされ、作業が始まります。変更したい点がある場合は、その内容を添えて計画を差し戻してください。",
5468
+ "body": "プランナーがこれらのフェーズと項目を起草しました。承認すると計画がコミットされ、作業が始まります。変更したい点がある場合は、その内容を添えて計画を差し戻してください。",
5469
5469
  "approve": "計画を承認",
5470
5470
  "feedbackPlaceholder": "プランナーに変更してほしい点は何ですか。範囲、フェーズの順序、不足している作業、別の場所に属する項目など…",
5471
5471
  "sendBack": "プランナーに差し戻す",
5472
- "noDocument": "この計画は、プラットフォームがレビュー用の計画を生成する前に作成されたため、たどれる文書がありません。下のセクションが計画そのものです。",
5472
+ "noDocument": "この計画ステップはレビュー用の計画ドキュメントを残さなかったため、ここにはたどれるものがありません。",
5473
+ "noDocumentSections": "下のセクションが計画そのものです。",
5473
5474
  "needsFeedback": "まずコメントかフィードバックを入力してください。プランナーはそれをもとに計画し直します。",
5474
5475
  "commentHint": "計画の任意の箇所をクリックするとコメントできます。"
5475
5476
  },
@@ -5453,11 +5453,12 @@
5453
5453
  },
5454
5454
  "planReview": {
5455
5455
  "title": "Ten plan czeka na Ciebie",
5456
- "body": "Planer przygotował poniższe fazy i elementy. Zatwierdź je, aby zapisać plan i rozpocząć pracę, albo odeślij plan z informacją, co zmienić.",
5456
+ "body": "Planer przygotował te fazy i elementy. Zatwierdź je, aby zapisać plan i rozpocząć pracę, albo odeślij plan z informacją, co zmienić.",
5457
5457
  "approve": "Zatwierdź plan",
5458
5458
  "feedbackPlaceholder": "Co planer ma zmienić? Zakres, kolejność faz, brakująca praca, element, który pasuje gdzie indziej…",
5459
5459
  "sendBack": "Odeślij do planera",
5460
- "noDocument": "Ten plan powstał, zanim platforma zaczęła generować plany do przeglądu, więc nie ma dokumentu do przeglądania — plan stanowią sekcje poniżej.",
5460
+ "noDocument": "Ten krok planowania nie pozostawił dokumentu planu do przeglądu, więc nie ma tu po czym nawigować.",
5461
+ "noDocumentSections": "Plan stanowią sekcje poniżej.",
5461
5462
  "needsFeedback": "Dodaj najpierw komentarz lub uwagi — planista na ich podstawie planuje ponownie.",
5462
5463
  "commentHint": "Kliknij dowolny fragment planu, aby go skomentować."
5463
5464
  },
@@ -5465,11 +5465,12 @@
5465
5465
  },
5466
5466
  "planReview": {
5467
5467
  "title": "Bu plan sizi bekliyor",
5468
- "body": "Planlayıcı aşağıdaki aşamaları ve maddeleri hazırladı. Planı kaydedip işe başlamak için onaylayın ya da neyin değişmesi gerektiğini yazarak planı geri gönderin.",
5468
+ "body": "Planlayıcı bu aşamaları ve maddeleri hazırladı. Planı kaydedip işe başlamak için onaylayın ya da neyin değişmesi gerektiğini yazarak planı geri gönderin.",
5469
5469
  "approve": "Planı onayla",
5470
5470
  "feedbackPlaceholder": "Planlayıcı neyi değiştirmeli? Kapsam, aşama sırası, eksik iş, başka yere ait bir madde…",
5471
5471
  "sendBack": "Planlayıcıya geri gönder",
5472
- "noDocument": "Bu plan, platform incelemeye yönelik planlar üretmeye başlamadan önce hazırlandı; gezinilecek bir belge yok — plan aşağıdaki bölümlerdir.",
5472
+ "noDocument": "Bu planlama adımı incelenecek bir plan belgesi bırakmadı, bu yüzden burada gezinilecek bir şey yok.",
5473
+ "noDocumentSections": "Plan aşağıdaki bölümlerdir.",
5473
5474
  "needsFeedback": "Önce bir yorum veya geri bildirim ekleyin — planlayıcı yeniden planlamak için bunu kullanır.",
5474
5475
  "commentHint": "Yorum yapmak için planın herhangi bir bölümüne tıklayın."
5475
5476
  },
@@ -5453,11 +5453,12 @@
5453
5453
  },
5454
5454
  "planReview": {
5455
5455
  "title": "Цей план чекає на вас",
5456
- "body": "Планувальник підготував фази та елементи нижче. Затвердіть їх, щоб зафіксувати план і розпочати роботу, або поверніть план із зазначенням, що змінити.",
5456
+ "body": "Планувальник підготував ці фази та елементи. Затвердіть їх, щоб зафіксувати план і розпочати роботу, або поверніть план із зазначенням, що змінити.",
5457
5457
  "approve": "Затвердити план",
5458
5458
  "feedbackPlaceholder": "Що має змінити планувальник? Обсяг, порядок фаз, пропущена робота, елемент, якому місце деінде…",
5459
5459
  "sendBack": "Повернути планувальнику",
5460
- "noDocument": "Цей план склали до того, як платформа почала формувати плани для перегляду, тож документа для навігації немає план становлять розділи нижче.",
5460
+ "noDocument": "Цей крок планування не залишив документа плану для перегляду, тож тут немає чого оглядати.",
5461
+ "noDocumentSections": "План становлять розділи нижче.",
5461
5462
  "needsFeedback": "Спершу додайте коментар або відгук — планувальник переплановує на його основі.",
5462
5463
  "commentHint": "Клацніть будь-яку частину плану, щоб залишити коментар."
5463
5464
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.195.0",
3
+ "version": "0.195.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",