@cat-factory/app 0.116.10 → 0.118.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.
@@ -76,6 +76,11 @@ const TASK_TYPES = computed<{ value: TaskTypeChoice; label: string; icon: string
76
76
  { value: 'bug', label: t('board.addTask.types.bug'), icon: 'i-lucide-bug' },
77
77
  { value: 'document', label: t('board.addTask.types.document'), icon: 'i-lucide-file-text' },
78
78
  { value: 'spike', label: t('board.addTask.types.spike'), icon: 'i-lucide-flask-conical' },
79
+ {
80
+ value: 'review',
81
+ label: t('board.addTask.types.review'),
82
+ icon: 'i-lucide-clipboard-check',
83
+ },
79
84
  { value: 'recurring', label: t('board.addTask.types.recurring'), icon: 'i-lucide-repeat' },
80
85
  ]
81
86
  // A document repository only accepts document/spike tasks (see BoardService.addTask).
@@ -103,6 +108,25 @@ const docKind = ref<DocKind | ''>('')
103
108
  const docAudience = ref('')
104
109
  const docTargetPath = ref('')
105
110
  const docOutlineHints = ref('')
111
+ // Review-task fields: the target PR (entered as a full URL or a bare #number) + optional
112
+ // review focus. The single input is parsed into the contract's `prUrl`/`prNumber` fields.
113
+ const reviewPrRef = ref('')
114
+ const reviewFocus = ref('')
115
+
116
+ // Parse the PR-reference input into the contract fields: a bare positive integer (optionally
117
+ // `#`-prefixed) becomes `prNumber` (a PR on the service's linked repo); anything else is taken
118
+ // as a full URL (`prUrl`). Returns undefined when blank or unparseable — the caller uses that
119
+ // to require a target on a review task.
120
+ function parseReviewPrRef(raw: string): Pick<TaskTypeFields, 'prUrl' | 'prNumber'> | undefined {
121
+ const trimmed = raw.trim()
122
+ if (!trimmed) return undefined
123
+ const bareNumber = /^#?(\d+)$/.exec(trimmed)
124
+ if (bareNumber) {
125
+ const n = Number(bareNumber[1])
126
+ return Number.isSafeInteger(n) && n >= 1 ? { prNumber: n } : undefined
127
+ }
128
+ return { prUrl: trimmed }
129
+ }
106
130
  // Per-kind specific fields (see DOC_KIND_FIELDS). Held in one keyed record; only the fields
107
131
  // for the selected kind are shown and submitted, so a value from a previously-selected kind is
108
132
  // never sent. The catalog keys below keep the labels/placeholders i18n and drift-guarded.
@@ -168,6 +192,11 @@ function buildTypeFields(): TaskTypeFields | undefined {
168
192
  }
169
193
  return Object.keys(f).length ? f : undefined
170
194
  }
195
+ if (taskType.value === 'review') {
196
+ const f: TaskTypeFields = { ...parseReviewPrRef(reviewPrRef.value) }
197
+ if (reviewFocus.value.trim()) f.reviewFocus = reviewFocus.value.trim()
198
+ return Object.keys(f).length ? f : undefined
199
+ }
171
200
  return undefined
172
201
  }
173
202
 
@@ -381,6 +410,8 @@ watch(open, (isOpen) => {
381
410
  docAudience.value = ''
382
411
  docTargetPath.value = ''
383
412
  docOutlineHints.value = ''
413
+ reviewPrRef.value = ''
414
+ reviewFocus.value = ''
384
415
  for (const key of Object.keys(docKindFieldValues) as DocKindFieldKey[])
385
416
  delete docKindFieldValues[key]
386
417
  riskPolicyId.value = ''
@@ -434,10 +465,13 @@ const { requestClose } = useUnsavedGuard({
434
465
  })
435
466
 
436
467
  // A recurring task only needs a target frame (its details are filled in the schedule
437
- // modal); every other type needs a title.
438
- const canAdd = computed(() =>
439
- isRecurring.value ? recurringFrameId.value !== null : title.value.trim().length > 0,
440
- )
468
+ // modal); every other type needs a title. A review task additionally needs a target PR.
469
+ const canAdd = computed(() => {
470
+ if (isRecurring.value) return recurringFrameId.value !== null
471
+ if (title.value.trim().length === 0) return false
472
+ if (taskType.value === 'review' && !parseReviewPrRef(reviewPrRef.value)) return false
473
+ return true
474
+ })
441
475
 
442
476
  async function add() {
443
477
  const containerId = ui.addTaskContainerId
@@ -720,6 +754,31 @@ async function add() {
720
754
  </UFormField>
721
755
  </div>
722
756
 
757
+ <div v-else-if="taskType === 'review'" class="space-y-3">
758
+ <UFormField
759
+ :label="t('board.addTask.review.prUrl')"
760
+ :hint="t('board.addTask.review.prUrlHint')"
761
+ required
762
+ >
763
+ <UInput
764
+ v-model="reviewPrRef"
765
+ placeholder="https://github.com/owner/repo/pull/123"
766
+ class="w-full"
767
+ />
768
+ </UFormField>
769
+ <UFormField
770
+ :label="t('board.addTask.review.focus')"
771
+ :hint="t('board.addTask.optional')"
772
+ >
773
+ <UTextarea
774
+ v-model="reviewFocus"
775
+ :rows="2"
776
+ :placeholder="t('board.addTask.review.focusPlaceholder')"
777
+ class="w-full"
778
+ />
779
+ </UFormField>
780
+ </div>
781
+
723
782
  <div class="grid grid-cols-2 gap-3">
724
783
  <UFormField :label="t('board.addTask.pipeline')">
725
784
  <UDropdownMenu :items="pipelineMenu" class="w-full">
@@ -64,6 +64,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent }> = {
64
64
  // the title opens the fork-decision window (see `reveal`); "act" just marks it read (the
65
65
  // choice is made in that window — pick a fork / enter a custom approach — not here).
66
66
  fork_decision_pending: { icon: 'i-lucide-git-fork', color: 'warning' },
67
+ // The PR reviewer surfaced findings to triage. Clicking the title opens the PR-review window
68
+ // (see `reveal`); "act" just marks it read (findings are selected in that window, not here).
69
+ pr_review_ready: { icon: 'i-lucide-clipboard-check', color: 'primary' },
67
70
  // The initiative loop needs attention (a blocked task, or completion). Clicking the title
68
71
  // opens the initiative tracker window; "act" just marks it read.
69
72
  initiative: { icon: 'i-lucide-milestone', color: 'primary' },
@@ -86,6 +89,7 @@ const ACTION_KEYS: Record<Notification['type'], string> = {
86
89
  human_review: 'layout.notifications.action.human_review',
87
90
  followup_pending: 'layout.notifications.action.followup_pending',
88
91
  fork_decision_pending: 'layout.notifications.action.fork_decision_pending',
92
+ pr_review_ready: 'layout.notifications.action.pr_review_ready',
89
93
  initiative: 'layout.notifications.action.initiative',
90
94
  }
91
95
 
@@ -157,6 +161,7 @@ function reveal(n: Notification) {
157
161
  else if (n.type === 'human_review') revealHumanReview(n)
158
162
  else if (n.type === 'followup_pending') revealFollowUps(n)
159
163
  else if (n.type === 'fork_decision_pending') revealForkDecision(n)
164
+ else if (n.type === 'pr_review_ready') revealPrReview(n)
160
165
  else if (n.type === 'initiative') ui.openInitiativeTracker(n.blockId)
161
166
  else ui.select(n.blockId)
162
167
  }
@@ -191,6 +196,15 @@ function revealForkDecision(n: Notification) {
191
196
  else if (n.blockId) ui.select(n.blockId)
192
197
  }
193
198
 
199
+ /**
200
+ * Open the PR deep-review window for a run parked awaiting a finding selection.
201
+ * Falls back to focusing the block when the run isn't loaded.
202
+ */
203
+ function revealPrReview(n: Notification) {
204
+ if (n.executionId && execution.getInstance(n.executionId)) ui.openPrReview(n.executionId)
205
+ else if (n.blockId) ui.select(n.blockId)
206
+ }
207
+
194
208
  /**
195
209
  * Open the human-testing window for a parked `human-test` gate: find the run's parked
196
210
  * human-test step and open it through the universal step dispatch (its archetype declares
@@ -23,6 +23,7 @@ import GenericStructuredResultView from '~/components/panels/GenericStructuredRe
23
23
  import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
24
24
  import FollowUpWindow from '~/components/followUp/FollowUpWindow.vue'
25
25
  import ForkDecisionWindow from '~/components/forkDecision/ForkDecisionWindow.vue'
26
+ import PrReviewWindow from '~/components/prReview/PrReviewWindow.vue'
26
27
  import MergerResultView from '~/components/panels/MergerResultView.vue'
27
28
  import InitiativeTrackerWindow from '~/components/initiative/InitiativeTrackerWindow.vue'
28
29
  import InitiativePlanningWindow from '~/components/initiative/InitiativePlanningWindow.vue'
@@ -57,6 +58,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
57
58
  // human's pick / custom approach. Opened directly via `ui.openForkDecision` (the pipeline
58
59
  // chip, the inspector button, and the `fork_decision_pending` card).
59
60
  'fork-decision': ForkDecisionWindow,
61
+ // The PR deep-review: the reviewer's sliced, prioritized findings + the human's multi-select.
62
+ // Opened as the `pr-reviewer` step's result view and via the `pr_review_ready` inbox card.
63
+ 'pr-review': PrReviewWindow,
60
64
  // The merger's verdict: the PR's complexity/risk/impact scores + the engine's auto-merge
61
65
  // or awaiting-review decision (and why), instead of the agent's raw JSON.
62
66
  merger: MergerResultView,
@@ -0,0 +1,289 @@
1
+ <script setup lang="ts">
2
+ // PR deep-review window — the dedicated surface for the read-only `pr-reviewer`'s sliced,
3
+ // prioritized findings, opened via the universal result-view host. It reads the live review
4
+ // state straight off the run's `pr-reviewer` step (`step.prReview`, kept fresh by the
5
+ // execution stream) and lets a human multi-SELECT which findings matter, grouped by slice and
6
+ // sorted by severity, then finish the review. The Fixer / inline-comment resolutions are the
7
+ // tracked PR 3 follow-up; this window's `Finish review` records the curated selection.
8
+ import { computed, ref, watch } from 'vue'
9
+ import { useResultView } from '~/composables/useResultView'
10
+ import { useExecutionStore } from '~/stores/execution'
11
+ import { useBoardStore } from '~/stores/board'
12
+ import { usePrReviewStore } from '~/stores/prReview'
13
+ import type { PrReviewFinding, PrReviewSeverity, PrReviewStepState } from '~/types/execution'
14
+
15
+ const execution = useExecutionStore()
16
+ const board = useBoardStore()
17
+ const prReview = usePrReviewStore()
18
+
19
+ const { t } = useI18n()
20
+
21
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('pr-review', {
22
+ onOpen: (_id) => {
23
+ if (instanceId.value) void prReview.load(instanceId.value)
24
+ },
25
+ })
26
+
27
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
28
+ const instance = computed(() =>
29
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
30
+ )
31
+ const step = computed(() => {
32
+ if (instance.value === null || stepIndex.value === null) return null
33
+ return instance.value.steps[stepIndex.value] ?? null
34
+ })
35
+ const state = computed<PrReviewStepState | null>(() => step.value?.prReview ?? null)
36
+ const status = computed(() => state.value?.status ?? null)
37
+ const awaiting = computed(() => status.value === 'awaiting_selection')
38
+ const findings = computed<PrReviewFinding[]>(() => state.value?.findings ?? [])
39
+
40
+ /** Severity → chip classes (styling, not copy). */
41
+ const SEVERITY_CLASS: Record<PrReviewSeverity, string> = {
42
+ blocker: 'bg-rose-500/15 text-rose-300 ring-rose-500/30',
43
+ high: 'bg-orange-500/15 text-orange-300 ring-orange-500/30',
44
+ medium: 'bg-amber-500/15 text-amber-300 ring-amber-500/30',
45
+ low: 'bg-sky-500/15 text-sky-300 ring-sky-500/30',
46
+ nit: 'bg-slate-500/15 text-slate-300 ring-slate-500/30',
47
+ }
48
+
49
+ /** Findings grouped under their slice (in the review's slice order), plus an "Other" bucket. */
50
+ const groups = computed(() => {
51
+ const slices = state.value?.slices ?? []
52
+ const byId = new Map<string, PrReviewFinding[]>()
53
+ const unsliced: PrReviewFinding[] = []
54
+ for (const f of findings.value) {
55
+ if (f.sliceId && slices.some((s) => s.id === f.sliceId)) {
56
+ const arr = byId.get(f.sliceId) ?? []
57
+ arr.push(f)
58
+ byId.set(f.sliceId, arr)
59
+ } else {
60
+ unsliced.push(f)
61
+ }
62
+ }
63
+ const out = slices
64
+ .map((s) => ({ id: s.id, title: s.title, rationale: s.rationale, items: byId.get(s.id) ?? [] }))
65
+ .filter((g) => g.items.length > 0)
66
+ if (unsliced.length > 0) {
67
+ out.push({ id: '__unsliced', title: t('prReview.unsliced'), rationale: '', items: unsliced })
68
+ }
69
+ return out
70
+ })
71
+
72
+ // The human's selection — a set of finding ids. Defaults to every finding (the human deselects
73
+ // the noise), so "Finish" without touching anything keeps all. Re-seeded ONLY when the set of
74
+ // finding IDS actually changes — NOT on every execution-stream re-emit (which hands us a fresh
75
+ // `findings` array reference on each reconnect/resync). Keying on the id set keeps the human's
76
+ // in-progress curation from being silently reset by an unrelated live update.
77
+ const findingIdKey = computed(() => findings.value.map((f) => f.id).join('\n'))
78
+ const selected = ref<Set<string>>(new Set())
79
+ watch(
80
+ findingIdKey,
81
+ () => {
82
+ selected.value = new Set(findings.value.map((f) => f.id))
83
+ },
84
+ { immediate: true },
85
+ )
86
+
87
+ function toggle(id: string): void {
88
+ const next = new Set(selected.value)
89
+ if (next.has(id)) next.delete(id)
90
+ else next.add(id)
91
+ selected.value = next
92
+ }
93
+ function selectAll(): void {
94
+ selected.value = new Set(findings.value.map((f) => f.id))
95
+ }
96
+ function clearAll(): void {
97
+ selected.value = new Set()
98
+ }
99
+
100
+ const canFinish = computed(() => awaiting.value && !prReview.resolving)
101
+
102
+ async function onFinish(): Promise<void> {
103
+ const id = instanceId.value
104
+ if (!id || !canFinish.value) return
105
+ await prReview.resolve(id, [...selected.value]).catch(() => {})
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <Teleport to="body">
111
+ <div
112
+ v-if="open"
113
+ data-testid="pr-review-window"
114
+ class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
115
+ @click.self="close"
116
+ >
117
+ <div
118
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
119
+ role="dialog"
120
+ aria-modal="true"
121
+ >
122
+ <!-- Header -->
123
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
124
+ <span
125
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300"
126
+ >
127
+ <UIcon name="i-lucide-clipboard-check" class="h-4 w-4" />
128
+ </span>
129
+ <div class="min-w-0 flex-1">
130
+ <h2 class="truncate text-sm font-semibold text-slate-100">
131
+ {{
132
+ block ? t('prReview.titleWithBlock', { title: block.title }) : t('prReview.title')
133
+ }}
134
+ </h2>
135
+ <p class="truncate text-[11px] text-slate-400">{{ t('prReview.subtitle') }}</p>
136
+ </div>
137
+ <a
138
+ v-if="state?.prUrl"
139
+ :href="state.prUrl"
140
+ target="_blank"
141
+ rel="noopener"
142
+ class="rounded-md px-2 py-1 text-[11px] text-indigo-300 hover:bg-slate-800"
143
+ >
144
+ {{ t('prReview.openPr') }}
145
+ </a>
146
+ <button
147
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
148
+ @click="close"
149
+ >
150
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
151
+ </button>
152
+ </header>
153
+
154
+ <div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
155
+ <!-- Reviewing: the read-only reviewer is still working. -->
156
+ <div
157
+ v-if="status === 'reviewing'"
158
+ class="flex h-full flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
159
+ >
160
+ <UIcon name="i-lucide-loader-circle" class="h-8 w-8 animate-spin opacity-60" />
161
+ <p class="text-sm">{{ t('prReview.reviewing.title') }}</p>
162
+ <p class="max-w-sm text-[11px] text-slate-500">{{ t('prReview.reviewing.hint') }}</p>
163
+ </div>
164
+
165
+ <template v-else>
166
+ <p
167
+ v-if="prReview.error"
168
+ class="mb-3 rounded-md bg-rose-500/10 px-3 py-2 text-[12px] text-rose-300"
169
+ >
170
+ {{ prReview.error }}
171
+ </p>
172
+
173
+ <!-- The reviewer's overall assessment. -->
174
+ <p
175
+ v-if="state?.summary"
176
+ class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
177
+ >
178
+ <span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
179
+ {{ state.summary }}
180
+ </p>
181
+
182
+ <!-- A clean PR / resolved review with no findings. -->
183
+ <div
184
+ v-if="findings.length === 0"
185
+ class="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-6 text-center text-[13px] text-slate-300"
186
+ >
187
+ {{ t('prReview.noFindings') }}
188
+ </div>
189
+
190
+ <template v-else>
191
+ <!-- Selection toolbar -->
192
+ <div v-if="awaiting" class="mb-2 flex items-center gap-3 text-[11px] text-slate-400">
193
+ <span data-testid="pr-review-selected-count">
194
+ {{ t('prReview.selectedCount', { count: selected.size }) }}
195
+ </span>
196
+ <button class="text-indigo-300 hover:underline" @click="selectAll">
197
+ {{ t('prReview.selectAll') }}
198
+ </button>
199
+ <button class="text-indigo-300 hover:underline" @click="clearAll">
200
+ {{ t('prReview.clear') }}
201
+ </button>
202
+ </div>
203
+
204
+ <!-- Findings grouped by slice -->
205
+ <section v-for="g in groups" :key="g.id" class="mb-4">
206
+ <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
207
+ {{ g.title }}
208
+ </h3>
209
+ <p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
210
+ {{ g.rationale }}
211
+ </p>
212
+ <article
213
+ v-for="f in g.items"
214
+ :key="f.id"
215
+ data-testid="pr-review-finding"
216
+ class="mb-1.5 rounded-xl border px-3 py-2 transition"
217
+ :class="
218
+ awaiting && selected.has(f.id)
219
+ ? 'border-indigo-500/60 bg-indigo-500/5'
220
+ : 'border-slate-800 bg-slate-900/60'
221
+ "
222
+ >
223
+ <div class="flex items-start gap-2">
224
+ <input
225
+ v-if="awaiting"
226
+ type="checkbox"
227
+ class="mt-1 accent-indigo-500"
228
+ data-testid="pr-review-finding-toggle"
229
+ :checked="selected.has(f.id)"
230
+ @change="toggle(f.id)"
231
+ />
232
+ <div class="min-w-0 flex-1">
233
+ <div class="flex flex-wrap items-center gap-1.5">
234
+ <span
235
+ class="rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase ring-1"
236
+ :class="SEVERITY_CLASS[f.severity]"
237
+ >
238
+ {{ t(`prReview.severity.${f.severity}`) }}
239
+ </span>
240
+ <span class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-300">
241
+ {{ t(`prReview.category.${f.category}`) }}
242
+ </span>
243
+ <h4 class="min-w-0 flex-1 text-[13px] font-medium text-slate-100">
244
+ {{ f.title }}
245
+ </h4>
246
+ </div>
247
+ <p class="mt-0.5 text-[11px] text-slate-500">
248
+ {{ f.path
249
+ }}<template v-if="f.line != null">
250
+ · {{ t('prReview.line', { line: f.line }) }}</template
251
+ >
252
+ </p>
253
+ <p class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300">
254
+ {{ f.detail }}
255
+ </p>
256
+ <p
257
+ v-if="f.suggestedFix"
258
+ class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
259
+ >
260
+ <span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
261
+ {{ f.suggestedFix }}
262
+ </p>
263
+ </div>
264
+ </div>
265
+ </article>
266
+ </section>
267
+ </template>
268
+ </template>
269
+ </div>
270
+
271
+ <!-- Footer -->
272
+ <footer
273
+ v-if="awaiting"
274
+ class="flex items-center justify-end gap-2 border-t border-slate-800 px-5 py-3"
275
+ >
276
+ <UButton
277
+ color="primary"
278
+ :loading="prReview.resolving"
279
+ :disabled="!canFinish"
280
+ data-testid="pr-review-finish"
281
+ @click="onFinish"
282
+ >
283
+ {{ t('prReview.finish') }}
284
+ </UButton>
285
+ </footer>
286
+ </div>
287
+ </div>
288
+ </Teleport>
289
+ </template>
@@ -98,6 +98,7 @@ const TASK_TYPE_KEYS: Record<CreateTaskType, string> = {
98
98
  bug: 'settings.workspaceSettings.taskTypes.bug',
99
99
  document: 'settings.workspaceSettings.taskTypes.document',
100
100
  spike: 'settings.workspaceSettings.taskTypes.spike',
101
+ review: 'settings.workspaceSettings.taskTypes.review',
101
102
  }
102
103
 
103
104
  const MODES = computed<{ value: TaskLimitMode; label: string }[]>(() => [
@@ -39,6 +39,7 @@ const ROUTABLE = computed<{ type: NotificationType; label: string }[]>(() => [
39
39
  { type: 'release_regression', label: t('slack.routable.release_regression') },
40
40
  { type: 'human_test_ready', label: t('slack.routable.human_test_ready') },
41
41
  { type: 'visual_confirmation_ready', label: t('slack.routable.visual_confirmation_ready') },
42
+ { type: 'pr_review_ready', label: t('slack.routable.pr_review_ready') },
42
43
  { type: 'initiative', label: t('slack.routable.initiative') },
43
44
  ])
44
45
 
@@ -64,6 +65,7 @@ const routes = reactive<Record<NotificationType, SlackRoute>>({
64
65
  human_review: { enabled: false, channel: '' },
65
66
  followup_pending: { enabled: false, channel: '' },
66
67
  fork_decision_pending: { enabled: false, channel: '' },
68
+ pr_review_ready: { enabled: false, channel: '' },
67
69
  initiative: { enabled: false, channel: '' },
68
70
  })
69
71
  const mentionsEnabled = ref(false)
@@ -0,0 +1,29 @@
1
+ import { getPrReviewContract, resolvePrReviewContract } from '@cat-factory/contracts'
2
+ import type { ApiContext } from './context'
3
+
4
+ /**
5
+ * The PR deep-review phase: the read-only `pr-reviewer` agent slices an open pull request and
6
+ * surfaces prioritized findings on the run's `pr-reviewer` step, then the run parks for a human
7
+ * to SELECT which findings matter. These endpoints read the surfaced findings and record the
8
+ * human's curated selection + resolution. The read returns null when no `pr-reviewer` step
9
+ * carries review state.
10
+ */
11
+ export function prReviewApi({ send, ws }: ApiContext) {
12
+ return {
13
+ // The live PR-review state for a run (null when no pr-reviewer step carries one).
14
+ getPrReview: (workspaceId: string, executionId: string) =>
15
+ send(getPrReviewContract, { pathPrefix: ws(workspaceId), pathParams: { executionId } }),
16
+
17
+ // Resolve a parked PR review: the curated finding selection + how it was resolved.
18
+ resolvePrReview: (
19
+ workspaceId: string,
20
+ executionId: string,
21
+ body: { action?: 'finish'; findingIds?: string[] },
22
+ ) =>
23
+ send(resolvePrReviewContract, {
24
+ pathPrefix: ws(workspaceId),
25
+ pathParams: { executionId },
26
+ body,
27
+ }),
28
+ }
29
+ }
@@ -9,6 +9,7 @@ import { documentsApi } from './api/documents'
9
9
  import { executionApi } from './api/execution'
10
10
  import { followUpsApi } from './api/followUps'
11
11
  import { forkDecisionApi } from './api/forkDecision'
12
+ import { prReviewApi } from './api/prReview'
12
13
  import { fragmentsApi } from './api/fragments'
13
14
  import { githubApi } from './api/github'
14
15
  import { humanReviewApi } from './api/humanReview'
@@ -110,6 +111,7 @@ export function useApi() {
110
111
  ...reviewsApi(ctx),
111
112
  ...followUpsApi(ctx),
112
113
  ...forkDecisionApi(ctx),
114
+ ...prReviewApi(ctx),
113
115
  ...humanTestApi(ctx),
114
116
  ...visualConfirmApi(ctx),
115
117
  ...humanReviewApi(ctx),
@@ -0,0 +1,80 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { PrReviewStepState } from '~/types/execution'
4
+ import { useApi } from '~/composables/useApi'
5
+ import { useWorkspaceStore } from '~/stores/workspace'
6
+ import { useExecutionStore } from '~/stores/execution'
7
+
8
+ /**
9
+ * The PR deep-review action surface. The live review state lives on the run's `pr-reviewer`
10
+ * step (`step.prReview`) and is kept fresh by the execution stream, so the window reads it
11
+ * straight off the execution store — this store only wraps the `resolve` action (and a warm-up
12
+ * `load`), tracks the in-flight state so the window can disable its controls, and reflects the
13
+ * returned state back onto the execution store so the UI updates immediately even before the
14
+ * stream echoes the change. Keyed by executionId, mirroring the fork-decision store.
15
+ */
16
+ export const usePrReviewStore = defineStore('prReview', () => {
17
+ const api = useApi()
18
+ const workspace = useWorkspaceStore()
19
+ const execution = useExecutionStore()
20
+
21
+ /** True while a resolve call is in flight (drives the Finish button spinner / disabled state). */
22
+ const resolving = ref(false)
23
+ /** The last error message from an action, surfaced inline; cleared on the next action. */
24
+ const error = ref<string | null>(null)
25
+
26
+ /**
27
+ * Reflect an authoritative PR-review state onto the run's `pr-reviewer` step. A pipeline could
28
+ * carry more than one such step, so target the step this review is about: prefer the step that
29
+ * is still awaiting a selection, then the current step, and only then the first step carrying
30
+ * review state. The stream corrects any mismatch; this keeps the immediate optimistic echo on
31
+ * the right step.
32
+ */
33
+ function reflect(executionId: string, state: PrReviewStepState | null): void {
34
+ if (!state) return
35
+ const instance = execution.getInstance(executionId)
36
+ if (!instance) return
37
+ const isLive = (s: (typeof instance.steps)[number]) =>
38
+ s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection'
39
+ const current = instance.steps[instance.currentStep]
40
+ const step =
41
+ instance.steps.find(isLive) ??
42
+ (current?.agentKind === 'pr-reviewer' && current.prReview ? current : undefined) ??
43
+ instance.steps.find((s) => s.prReview)
44
+ if (step) step.prReview = state
45
+ }
46
+
47
+ /** Warm the live state from the GET (the stream also keeps it fresh). Best-effort. */
48
+ async function load(executionId: string): Promise<void> {
49
+ error.value = null
50
+ try {
51
+ const state = await api.getPrReview(workspace.requireId(), executionId)
52
+ reflect(executionId, state as PrReviewStepState | null)
53
+ } catch (e) {
54
+ error.value = e instanceof Error ? e.message : 'Failed to load'
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Resolve the review: record the curated finding selection and complete the read-only review
60
+ * (the run then advances to done). PR 2 supports only the `finish` action.
61
+ */
62
+ async function resolve(executionId: string, findingIds: string[]): Promise<void> {
63
+ error.value = null
64
+ resolving.value = true
65
+ try {
66
+ const state = await api.resolvePrReview(workspace.requireId(), executionId, {
67
+ action: 'finish',
68
+ findingIds,
69
+ })
70
+ reflect(executionId, state as PrReviewStepState)
71
+ } catch (e) {
72
+ error.value = e instanceof Error ? e.message : 'Failed to resolve review'
73
+ throw e
74
+ } finally {
75
+ resolving.value = false
76
+ }
77
+ }
78
+
79
+ return { resolving, error, load, resolve }
80
+ })
package/app/stores/ui.ts CHANGED
@@ -834,6 +834,31 @@ export const useUiStore = defineStore('ui', () => {
834
834
  stepIndex: idx,
835
835
  }
836
836
  }
837
+ // Open the PR deep-review window for a run's `pr-reviewer` step (from the `pr_review_ready`
838
+ // notification / the step). Resolves the step index from the run when not given, preferring
839
+ // the step parked awaiting a finding selection.
840
+ function openPrReview(instanceId: string, stepIndex: number | null = null) {
841
+ const execution = useExecutionStore()
842
+ const instance = execution.getInstance(instanceId)
843
+ if (!instance) return
844
+ const resolveIdx = () => {
845
+ const awaiting = instance.steps.findIndex(
846
+ (s) => s.agentKind === 'pr-reviewer' && s.prReview?.status === 'awaiting_selection',
847
+ )
848
+ if (awaiting >= 0) return awaiting
849
+ const current = instance.steps[instance.currentStep]
850
+ if (current?.agentKind === 'pr-reviewer' && current.prReview) return instance.currentStep
851
+ return instance.steps.findIndex((s) => s.agentKind === 'pr-reviewer' && s.prReview)
852
+ }
853
+ const idx = stepIndex ?? resolveIdx()
854
+ if (idx < 0) return
855
+ resultView.value = {
856
+ view: 'pr-review',
857
+ blockId: instance.blockId,
858
+ instanceId,
859
+ stepIndex: idx,
860
+ }
861
+ }
837
862
  function closeResultView() {
838
863
  resultView.value = null
839
864
  }
@@ -1032,6 +1057,7 @@ export const useUiStore = defineStore('ui', () => {
1032
1057
  openInitiativePlanning,
1033
1058
  openFollowUps,
1034
1059
  openForkDecision,
1060
+ openPrReview,
1035
1061
  closeRequirementReview,
1036
1062
  openStepDetail,
1037
1063
  closeStepDetail,
@@ -38,6 +38,11 @@ export type {
38
38
  ForkDecisionStatus,
39
39
  ForkChoice,
40
40
  ForkDecisionStepState,
41
+ PrReviewStepState,
42
+ PrReviewFinding,
43
+ PrReviewSlice,
44
+ PrReviewSeverity,
45
+ PrReviewCategory,
41
46
  GateFailingCheck,
42
47
  GateAttempt,
43
48
  GateStepState,