@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
@@ -12,7 +12,7 @@
12
12
  // step's park routes (its archetype declares this result view), so the approve /
13
13
  // request-changes rail has to live here or the gate has no resolving surface at all —
14
14
  // which is exactly how an approved-only-over-REST plan gate shipped.
15
- import { computed, reactive, ref, watch } from 'vue'
15
+ import { computed, reactive, ref } from 'vue'
16
16
  import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
17
17
  import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
18
18
  import {
@@ -26,10 +26,10 @@ import {
26
26
  } from '~/utils/initiative'
27
27
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
28
28
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
29
+ import InitiativePlanReview from '~/components/initiative/InitiativePlanReview.vue'
29
30
 
30
31
  const board = useBoardStore()
31
32
  const initiatives = useInitiativesStore()
32
- const execution = useExecutionStore()
33
33
  const access = useWorkspaceAccess()
34
34
  const { t } = useI18n()
35
35
  const toast = useToast()
@@ -106,52 +106,6 @@ async function checkpointControl(action: 'resume' | 'cancel') {
106
106
  // human parked on the gate actually uses. So the rail appears on every route into the window.
107
107
  const { planApproval } = useInitiativePlanning(() => blockId.value ?? '')
108
108
 
109
- /** Draft feedback for "request changes" (the planner re-runs with it), and the rail's in-flight
110
- * state. Reset when a different initiative opens so a draft can't follow the window. */
111
- const planFeedback = ref('')
112
- const requestingChanges = ref(false)
113
- const resolvingPlan = ref(false)
114
- watch(blockId, () => {
115
- planFeedback.value = ''
116
- requestingChanges.value = false
117
- })
118
-
119
- const canRequestChanges = computed(() => planFeedback.value.trim().length > 0)
120
-
121
- /**
122
- * Accept the drafted plan: the run advances to the committer, which persists the initiative and
123
- * arms the execution loop. The window deliberately stays OPEN — the rail disappears with the
124
- * approval (live), and the tracker is where the plan then starts executing.
125
- */
126
- async function approvePlan() {
127
- const parked = planApproval.value
128
- if (!parked || resolvingPlan.value) return
129
- resolvingPlan.value = true
130
- try {
131
- await execution.approveStep(parked.instanceId, parked.approval.id)
132
- } finally {
133
- resolvingPlan.value = false
134
- }
135
- }
136
-
137
- /** Send the plan back to the planner with what to change; it re-plans and parks again. */
138
- async function submitPlanChanges() {
139
- const parked = planApproval.value
140
- if (!parked || resolvingPlan.value || !canRequestChanges.value) return
141
- resolvingPlan.value = true
142
- try {
143
- const ok = await execution.requestStepChanges(parked.instanceId, parked.approval.id, {
144
- feedback: planFeedback.value.trim(),
145
- })
146
- if (ok) {
147
- planFeedback.value = ''
148
- requestingChanges.value = false
149
- }
150
- } finally {
151
- resolvingPlan.value = false
152
- }
153
- }
154
-
155
109
  const policyRules = computed(() => initiative.value?.policy?.rules ?? [])
156
110
  function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?: number }): string {
157
111
  const axes = [
@@ -294,85 +248,17 @@ async function savePolicy() {
294
248
  </div>
295
249
 
296
250
  <template v-else>
297
- <!-- The planner's human gate: the plan below is drafted but NOT yet committed. This
298
- is the only surface that can resolve it (the generic approval panel is never
299
- reached — the planner's archetype routes its park to this window), so approve /
300
- request changes live here, beside the plan they judge. -->
301
- <section
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
+ itand 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
302
256
  v-if="planApproval"
303
- class="mb-4 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3.5"
304
- data-testid="initiative-plan-review"
305
- >
306
- <div class="flex items-start gap-2.5">
307
- <UIcon
308
- name="i-lucide-clipboard-check"
309
- class="mt-0.5 h-4 w-4 shrink-0 text-amber-300"
310
- />
311
- <div class="min-w-0 flex-1">
312
- <h3 class="text-[13px] font-semibold text-amber-200">
313
- {{ t('initiative.planReview.title') }}
314
- </h3>
315
- <p class="mt-0.5 text-[12px] leading-relaxed text-amber-100/80">
316
- {{ t('initiative.planReview.body') }}
317
- </p>
318
- <div v-if="!requestingChanges" class="mt-2.5 flex flex-wrap gap-2">
319
- <button
320
- class="rounded bg-indigo-600 px-2.5 py-1 text-[11px] font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
321
- :disabled="resolvingPlan || !access.canExecuteRuns.value"
322
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
323
- data-testid="initiative-plan-approve"
324
- @click="approvePlan"
325
- >
326
- {{ t('initiative.planReview.approve') }}
327
- </button>
328
- <button
329
- class="rounded border border-amber-400/50 px-2.5 py-1 text-[11px] font-medium text-amber-200 hover:bg-amber-500/10 disabled:opacity-50"
330
- :disabled="resolvingPlan || !access.canExecuteRuns.value"
331
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
332
- data-testid="initiative-plan-request-changes"
333
- @click="requestingChanges = true"
334
- >
335
- {{ t('initiative.planReview.requestChanges') }}
336
- </button>
337
- </div>
338
- <!-- Request-changes composer: the feedback is what the planner re-plans FROM,
339
- so it is required — an empty send would re-run the planner with nothing
340
- to act on and park again on the same plan. -->
341
- <div v-else class="mt-2.5">
342
- <UTextarea
343
- v-model="planFeedback"
344
- :rows="3"
345
- autoresize
346
- size="sm"
347
- class="w-full"
348
- data-testid="initiative-plan-feedback"
349
- :placeholder="t('initiative.planReview.feedbackPlaceholder')"
350
- />
351
- <div class="mt-2 flex flex-wrap gap-2">
352
- <button
353
- class="rounded bg-amber-500 px-2.5 py-1 text-[11px] font-medium text-slate-950 hover:bg-amber-400 disabled:opacity-50"
354
- :disabled="
355
- resolvingPlan || !canRequestChanges || !access.canExecuteRuns.value
356
- "
357
- :title="access.canExecuteRuns.value ? undefined : t('access.noRunExecute')"
358
- data-testid="initiative-plan-send-back"
359
- @click="submitPlanChanges"
360
- >
361
- {{ t('initiative.planReview.sendBack') }}
362
- </button>
363
- <button
364
- class="rounded border border-slate-600 px-2.5 py-1 text-[11px] font-medium text-slate-300 hover:bg-slate-800 disabled:opacity-50"
365
- :disabled="resolvingPlan"
366
- data-testid="initiative-plan-cancel-changes"
367
- @click="requestingChanges = false"
368
- >
369
- {{ t('common.cancel') }}
370
- </button>
371
- </div>
372
- </div>
373
- </div>
374
- </div>
375
- </section>
257
+ :approval="planApproval.approval"
258
+ :instance-id="planApproval.instanceId"
259
+ :can-execute="access.canExecuteRuns.value"
260
+ :output-is-rendered="planApproval.outputIsRendered"
261
+ />
376
262
 
377
263
  <!-- Paused at a phase checkpoint (D2): a completed checkpoint phase is awaiting
378
264
  review before the next phase spawns. Read the phase's artifacts/PRs below,
@@ -148,6 +148,14 @@ const {
148
148
 
149
149
  const approvalPending = computed(() => step.value?.approval?.status === 'pending')
150
150
  const approvalId = computed(() => step.value?.approval?.id ?? null)
151
+ // Whether "approve with corrections" applies. A step whose output is a deterministic
152
+ // RENDERING of an artifact it already produced (the initiative plan, the spec doc, the
153
+ // blueprint tree — `step.outputIsRendered`, set by the engine's `reviewableArtifactOutput`
154
+ // seam) has no editable proposal: the artifact was ingested into domain state before the gate
155
+ // was raised, so an edit typed over the rendering would never reach it. The backend refuses
156
+ // such an edit; hiding the affordance is what stops a reviewer typing corrections into a
157
+ // dead end. Their route is "request changes", which re-runs the producer with the correction.
158
+ const proposalEditable = computed(() => step.value?.outputIsRendered !== true)
151
159
  // A companion step parked at its automatic-rework cap: instead of the generic
152
160
  // approve/request-changes/reject rail, it shows the shared iteration-cap prompt
153
161
  // (one more round / proceed / stop & reset), resolved through its own endpoint.
@@ -261,6 +269,7 @@ async function copyOutput() {
261
269
  <!-- ToC sidebar (only meaningful when there are prose headings) -->
262
270
  <aside
263
271
  v-if="outline.hasToc"
272
+ data-testid="step-detail-toc"
264
273
  class="hidden w-72 shrink-0 flex-col border-e border-slate-800 bg-slate-900/60 md:flex"
265
274
  >
266
275
  <div class="border-b border-slate-800 px-4 py-3">
@@ -646,6 +655,7 @@ async function copyOutput() {
646
655
  <!-- composer for the block the human just clicked -->
647
656
  <div
648
657
  v-if="draftTarget"
658
+ data-testid="step-review-composer"
649
659
  class="rounded-lg border border-indigo-500/40 bg-indigo-500/5 p-3"
650
660
  >
651
661
  <div class="mb-1 text-[10px] uppercase tracking-wide text-indigo-300">
@@ -656,6 +666,7 @@ async function copyOutput() {
656
666
  >{{ draftTarget.quotedSource }}</pre>
657
667
  <UTextarea
658
668
  v-model="draftBody"
669
+ data-testid="step-review-comment-body"
659
670
  :rows="3"
660
671
  autoresize
661
672
  size="sm"
@@ -669,6 +680,7 @@ async function copyOutput() {
669
680
  <UButton
670
681
  color="primary"
671
682
  size="xs"
683
+ data-testid="step-review-comment-add"
672
684
  :disabled="!draftBody.trim()"
673
685
  @click="addDraftComment"
674
686
  >
@@ -681,6 +693,7 @@ async function copyOutput() {
681
693
  <div
682
694
  v-for="(c, idx) in reviewComments"
683
695
  :key="idx"
696
+ data-testid="step-review-comment"
684
697
  class="rounded-lg border border-slate-800 bg-slate-900/50 p-3"
685
698
  >
686
699
  <div class="mb-1 flex items-start justify-between gap-2">
@@ -709,6 +722,7 @@ async function copyOutput() {
709
722
  </label>
710
723
  <UTextarea
711
724
  v-model="feedback"
725
+ data-testid="step-review-feedback"
712
726
  :rows="3"
713
727
  autoresize
714
728
  size="sm"
@@ -763,6 +777,7 @@ async function copyOutput() {
763
777
  {{ t('panels.stepDetail.approveAndProceed') }}
764
778
  </UButton>
765
779
  <UButton
780
+ v-if="proposalEditable"
766
781
  color="primary"
767
782
  variant="soft"
768
783
  size="sm"
@@ -773,6 +788,9 @@ async function copyOutput() {
773
788
  >
774
789
  {{ t('panels.stepDetail.approveWithCorrections') }}
775
790
  </UButton>
791
+ <p v-else class="text-[10px] text-slate-500" data-testid="step-rendered-output-note">
792
+ {{ t('panels.stepDetail.renderedOutputNote') }}
793
+ </p>
776
794
 
777
795
  <!-- destructive: a two-step inline confirm instead of a native dialog -->
778
796
  <div
@@ -812,6 +830,7 @@ async function copyOutput() {
812
830
  size="sm"
813
831
  icon="i-lucide-rotate-ccw"
814
832
  class="flex-1"
833
+ data-testid="step-request-changes"
815
834
  :disabled="!canRequestChanges"
816
835
  :loading="submitting"
817
836
  @click="requestChanges"
@@ -849,126 +868,4 @@ async function copyOutput() {
849
868
  .reader-fade-leave-to {
850
869
  opacity: 0;
851
870
  }
852
-
853
- /* Approval mode: each source-mapped block becomes a comment target — a hover
854
- highlight + a "+" gutter affordance, GitHub-review style. */
855
- .reader-prose.review-mode :deep([data-src-start]) {
856
- position: relative;
857
- cursor: pointer;
858
- border-radius: 0.375rem;
859
- transition: background 0.12s ease;
860
- }
861
- .reader-prose.review-mode :deep([data-src-start]:hover) {
862
- background: rgb(99 102 241 / 0.08);
863
- box-shadow: inset 2px 0 0 rgb(99 102 241 / 0.5);
864
- }
865
- .reader-prose.review-mode :deep([data-src-start])::before {
866
- content: '+';
867
- position: absolute;
868
- left: -1.4rem;
869
- top: 0.1rem;
870
- display: none;
871
- height: 1.1rem;
872
- width: 1.1rem;
873
- align-items: center;
874
- justify-content: center;
875
- border-radius: 0.25rem;
876
- background: rgb(99 102 241);
877
- color: white;
878
- font-size: 0.8rem;
879
- line-height: 1;
880
- }
881
- .reader-prose.review-mode :deep([data-src-start]:hover)::before {
882
- display: flex;
883
- }
884
- /* Persistent markers: amber for a block that already has a comment, indigo for
885
- the block whose composer is currently open. */
886
- .reader-prose :deep(.cf-commented) {
887
- background: rgb(234 179 8 / 0.1);
888
- box-shadow: inset 2px 0 0 rgb(234 179 8 / 0.6);
889
- }
890
- .reader-prose :deep(.cf-selected) {
891
- background: rgb(99 102 241 / 0.12);
892
- box-shadow: inset 2px 0 0 rgb(99 102 241 / 0.8);
893
- }
894
-
895
- /* Styling for the markdown HTML injected via v-html (out of scoped reach without
896
- :deep), kept close to the inspector's existing prose styling. */
897
- .reader-prose :deep(p) {
898
- margin: 0.5rem 0;
899
- }
900
- .reader-prose :deep(ul),
901
- .reader-prose :deep(ol) {
902
- margin: 0.5rem 0;
903
- padding-left: 1.25rem;
904
- }
905
- .reader-prose :deep(ul) {
906
- list-style: disc;
907
- }
908
- .reader-prose :deep(ol) {
909
- list-style: decimal;
910
- }
911
- .reader-prose :deep(li) {
912
- margin: 0.2rem 0;
913
- }
914
- .reader-prose :deep(strong) {
915
- font-weight: 600;
916
- color: rgb(226 232 240);
917
- }
918
- .reader-prose :deep(em) {
919
- font-style: italic;
920
- }
921
- .reader-prose :deep(code) {
922
- border-radius: 0.25rem;
923
- background: rgb(30 41 59 / 0.8);
924
- padding: 0.1rem 0.3rem;
925
- font-family: ui-monospace, monospace;
926
- font-size: 0.85em;
927
- color: rgb(199 210 254);
928
- }
929
- .reader-prose :deep(pre) {
930
- margin: 0.6rem 0;
931
- overflow: auto;
932
- border-radius: 0.5rem;
933
- background: rgb(2 6 23 / 0.6);
934
- padding: 0.75rem 0.9rem;
935
- }
936
- .reader-prose :deep(pre code) {
937
- background: transparent;
938
- padding: 0;
939
- color: rgb(203 213 225);
940
- }
941
- .reader-prose :deep(blockquote) {
942
- margin: 0.6rem 0;
943
- border-left: 3px solid rgb(99 102 241 / 0.5);
944
- padding-left: 0.75rem;
945
- color: rgb(148 163 184);
946
- }
947
- .reader-prose :deep(table) {
948
- margin: 0.6rem 0;
949
- border-collapse: collapse;
950
- font-size: 0.95em;
951
- }
952
- .reader-prose :deep(th),
953
- .reader-prose :deep(td) {
954
- border: 1px solid rgb(51 65 85);
955
- padding: 0.3rem 0.6rem;
956
- }
957
- .reader-prose :deep(th) {
958
- background: rgb(30 41 59 / 0.6);
959
- font-weight: 600;
960
- }
961
- .reader-prose :deep(hr) {
962
- margin: 1rem 0;
963
- border: none;
964
- border-top: 1px solid rgb(51 65 85);
965
- }
966
- .reader-prose :deep(h1),
967
- .reader-prose :deep(h2),
968
- .reader-prose :deep(h3),
969
- .reader-prose :deep(h4) {
970
- margin: 0.6rem 0 0.3rem;
971
- font-weight: 600;
972
- color: rgb(226 232 240);
973
- }
974
871
  </style>
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ // The pull request a `review` task is reviewing — its SUBJECT, not its output. Distinct from the
3
+ // Execution panel's "Pull request", which links the PR a run PRODUCED: a review task produces no
4
+ // PR at all, so without this the one thing identifying the task was buried in the folded
5
+ // description text with nothing to click.
6
+ //
7
+ // Reads the task's own fields, so it is there from creation — before any run — and stays there
8
+ // after one. The backend confirms the PR against the provider at create time and records that
9
+ // provider's own link (`prUrl`), which is what makes a plain read enough here; a task created
10
+ // while no VCS was connected keeps only the number, and then the reference reads as text rather
11
+ // than pretending to be a link.
12
+ import { computed } from 'vue'
13
+ import type { Block } from '~/types/domain'
14
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
15
+
16
+ const props = defineProps<{ block: Block }>()
17
+ const { t } = useI18n()
18
+
19
+ const isReview = computed(() => props.block.taskType === 'review')
20
+ const fields = computed(() => props.block.taskTypeFields ?? null)
21
+ const url = computed(() => fields.value?.prUrl?.trim() || '')
22
+ const focus = computed(() => fields.value?.reviewFocus?.trim() || '')
23
+
24
+ /** `#123` when the number is known, else the raw link — never an empty affordance. */
25
+ const label = computed(() => {
26
+ const number = fields.value?.prNumber
27
+ return number ? t('inspector.reviewTarget.prNumber', { number }) : url.value
28
+ })
29
+
30
+ /** Nothing to show when the task carries no reference at all (nothing to link or name). */
31
+ const hasTarget = computed(() => Boolean(label.value))
32
+ </script>
33
+
34
+ <template>
35
+ <InspectorSection
36
+ v-if="isReview && hasTarget"
37
+ :title="t('inspector.reviewTarget.title')"
38
+ :hint="t('inspector.reviewTarget.hint')"
39
+ icon="i-lucide-git-pull-request-arrow"
40
+ default-open
41
+ >
42
+ <UButton
43
+ v-if="url"
44
+ :to="url"
45
+ target="_blank"
46
+ rel="noopener"
47
+ external
48
+ color="neutral"
49
+ variant="soft"
50
+ size="sm"
51
+ icon="i-lucide-git-pull-request"
52
+ trailing-icon="i-lucide-external-link"
53
+ block
54
+ data-testid="inspector-review-target-link"
55
+ >
56
+ <span class="w-full truncate text-start" :title="url">{{ label }}</span>
57
+ </UButton>
58
+ <p
59
+ v-else
60
+ class="rounded-lg border border-slate-800 bg-slate-900/40 p-2.5 text-xs text-slate-300"
61
+ data-testid="inspector-review-target-link"
62
+ >
63
+ {{ label }}
64
+ </p>
65
+ <p v-if="focus" class="text-xs leading-relaxed text-slate-500">
66
+ {{ t('inspector.reviewTarget.focus', { focus }) }}
67
+ </p>
68
+ </InspectorSection>
69
+ </template>
@@ -1228,41 +1228,8 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1228
1228
  .pl-5\.5 {
1229
1229
  padding-left: 1.375rem;
1230
1230
  }
1231
- /* Minimal CommonMark styling for the incorporated requirements reader (mirrors the
1232
- prose review window's reader-prose). */
1233
- .reader-prose :deep(p) {
1234
- margin: 0.4rem 0;
1235
- }
1236
- .reader-prose :deep(ul),
1237
- .reader-prose :deep(ol) {
1238
- margin: 0.4rem 0;
1239
- padding-left: 1.25rem;
1240
- list-style: revert;
1241
- }
1242
- .reader-prose :deep(li) {
1243
- margin: 0.2rem 0;
1244
- }
1245
- .reader-prose :deep(strong) {
1246
- color: rgb(226 232 240);
1247
- font-weight: 600;
1248
- }
1249
- .reader-prose :deep(code) {
1250
- border-radius: 0.25rem;
1251
- background: rgb(2 6 23 / 0.6);
1252
- padding: 0.05rem 0.3rem;
1253
- font-size: 0.85em;
1254
- }
1255
- .reader-prose :deep(pre) {
1256
- margin: 0.5rem 0;
1257
- overflow-x: auto;
1258
- border-radius: 0.5rem;
1259
- background: rgb(2 6 23 / 0.6);
1260
- padding: 0.75rem;
1261
- }
1262
- .reader-prose :deep(blockquote) {
1263
- margin: 0.5rem 0;
1264
- border-left: 2px solid rgb(51 65 85);
1265
- padding-left: 0.75rem;
1266
- color: rgb(148 163 184);
1267
- }
1231
+ /* The rendered-markdown presentation is the SHARED global `.reader-prose` sheet
1232
+ (`assets/css/prose.css`), not a local copy: this reader shows the same agent-authored
1233
+ markdown the step reader does, and a per-window duplicate is how the review surfaces
1234
+ drift apart one property at a time. */
1268
1235
  </style>
@@ -0,0 +1,126 @@
1
+ import { computed, nextTick, ref, watch } from 'vue'
2
+ import { sliceSource } from '~/utils/agentOutput'
3
+
4
+ /** A draft per-block review comment, anchored to a source line range of the reviewed markdown. */
5
+ export interface ProseComment {
6
+ srcStart: number
7
+ srcEnd: number
8
+ quotedSource: string
9
+ body: string
10
+ }
11
+
12
+ /**
13
+ * GitHub-style per-block commenting over a rendered markdown document — the anchoring half of a
14
+ * prose review, with no opinion about WHAT is being reviewed or how the review is submitted.
15
+ *
16
+ * Extracted from `useStepApproval` (which now builds on it) so a second surface can offer the
17
+ * same affordance: the initiative tracker's plan-approval rail reviews the planner's drafted plan,
18
+ * which the engine renders as markdown onto the gate's proposal. Both therefore anchor comments
19
+ * identically — a comment quotes the exact source lines of the block it targets, so a "request
20
+ * changes" re-run can hand the agent back its OWN text rather than a re-rendered approximation.
21
+ *
22
+ * The anchors are the `data-src-start`/`data-src-end` attributes `parseOutputOutline` stamps on
23
+ * every top-level block, so this works over any document that reader renders.
24
+ *
25
+ * @param output the raw markdown being reviewed (comments quote out of it by line range)
26
+ * @param root the element containing the rendered blocks, for the highlight sync
27
+ * @param enabled whether clicking a block should start a comment (review mode is on)
28
+ */
29
+ export function useProseComments(opts: {
30
+ output: () => string
31
+ root: () => HTMLElement | null
32
+ enabled: () => boolean
33
+ }) {
34
+ const comments = ref<ProseComment[]>([])
35
+ const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
36
+ const draftBody = ref('')
37
+
38
+ const blockKey = (c: { srcStart: number; srcEnd: number }) => `${c.srcStart}:${c.srcEnd}`
39
+
40
+ /** Toggle the highlight classes on commented / selected blocks within the rendered document. */
41
+ function syncHighlights() {
42
+ const root = opts.root()
43
+ if (!root) return
44
+ const commented = new Set(comments.value.map(blockKey))
45
+ const selected = draftTarget.value ? blockKey(draftTarget.value) : null
46
+ for (const el of Array.from(root.querySelectorAll('[data-src-start]'))) {
47
+ const key = `${el.getAttribute('data-src-start')}:${el.getAttribute('data-src-end')}`
48
+ el.classList.toggle('cf-commented', commented.has(key))
49
+ el.classList.toggle('cf-selected', key === selected)
50
+ }
51
+ }
52
+
53
+ /** Click a rendered block to start commenting on it (links keep working). */
54
+ function onProseClick(e: MouseEvent) {
55
+ if (!opts.enabled()) return
56
+ const target = e.target as HTMLElement
57
+ if (target.closest('a')) return
58
+ const blockEl = target.closest('[data-src-start]') as HTMLElement | null
59
+ if (!blockEl) return
60
+ const srcStart = Number(blockEl.getAttribute('data-src-start'))
61
+ const srcEnd = Number(blockEl.getAttribute('data-src-end'))
62
+ if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
63
+ draftTarget.value = {
64
+ srcStart,
65
+ srcEnd,
66
+ quotedSource: sliceSource(opts.output(), srcStart, srcEnd),
67
+ }
68
+ draftBody.value = ''
69
+ void nextTick(syncHighlights)
70
+ }
71
+
72
+ function addDraftComment() {
73
+ if (!draftTarget.value || !draftBody.value.trim()) return
74
+ comments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
75
+ draftTarget.value = null
76
+ draftBody.value = ''
77
+ void nextTick(syncHighlights)
78
+ }
79
+ function cancelDraft() {
80
+ draftTarget.value = null
81
+ draftBody.value = ''
82
+ void nextTick(syncHighlights)
83
+ }
84
+ function removeComment(idx: number) {
85
+ comments.value.splice(idx, 1)
86
+ void nextTick(syncHighlights)
87
+ }
88
+
89
+ /** Drop every draft — a different document (or a different subject) is being reviewed. */
90
+ function reset() {
91
+ comments.value = []
92
+ draftTarget.value = null
93
+ draftBody.value = ''
94
+ void nextTick(syncHighlights)
95
+ }
96
+
97
+ /** The wire shape `requestStepChanges` takes, or undefined when nothing was anchored. */
98
+ const wireComments = computed(() =>
99
+ comments.value.length
100
+ ? comments.value.map((c) => ({
101
+ quotedSource: c.quotedSource,
102
+ srcStart: c.srcStart,
103
+ srcEnd: c.srcEnd,
104
+ body: c.body,
105
+ }))
106
+ : undefined,
107
+ )
108
+
109
+ // Keep the in-document highlights in sync as the document renders or the drafts change.
110
+ watch([opts.enabled, opts.output, comments, draftTarget], () => void nextTick(syncHighlights), {
111
+ deep: true,
112
+ })
113
+
114
+ return {
115
+ comments,
116
+ wireComments,
117
+ draftTarget,
118
+ draftBody,
119
+ syncHighlights,
120
+ onProseClick,
121
+ addDraftComment,
122
+ cancelDraft,
123
+ removeComment,
124
+ reset,
125
+ }
126
+ }