@cat-factory/app 0.303.4 → 0.305.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 (34) hide show
  1. package/app/components/guidedReview/GuidedReview.logic.spec.ts +123 -0
  2. package/app/components/guidedReview/GuidedReview.logic.ts +108 -0
  3. package/app/components/guidedReview/GuidedReviewFailure.vue +37 -0
  4. package/app/components/guidedReview/GuidedReviewModal.vue +243 -0
  5. package/app/components/guidedReview/GuidedReviewOverview.vue +160 -0
  6. package/app/components/guidedReview/GuidedReviewPicker.vue +95 -0
  7. package/app/components/guidedReview/GuidedReviewThread.vue +177 -0
  8. package/app/components/panels/inspector/TaskReviewTarget.vue +36 -0
  9. package/app/composables/api/guidedReview.ts +80 -0
  10. package/app/composables/useApi.ts +2 -0
  11. package/app/composables/useNavContributions.ts +1 -0
  12. package/app/composables/usePipelineErrorToast.ts +4 -0
  13. package/app/composables/useWorkspaceStream.ts +4 -0
  14. package/app/composables/workspaceStream/applyWorkspaceEvent.spec.ts +2 -0
  15. package/app/composables/workspaceStream/applyWorkspaceEvent.ts +6 -0
  16. package/app/modular/nav-contributions.ts +20 -0
  17. package/app/pages/index.vue +4 -0
  18. package/app/stores/guidedReview.spec.ts +148 -0
  19. package/app/stores/guidedReview.ts +200 -0
  20. package/app/stores/ui/modals.ts +25 -1
  21. package/app/stores/workspace/hydrate.ts +2 -0
  22. package/app/types/domain.ts +1 -0
  23. package/app/types/guided-review.ts +16 -0
  24. package/i18n/locales/de.json +82 -2
  25. package/i18n/locales/en.json +82 -2
  26. package/i18n/locales/es.json +82 -2
  27. package/i18n/locales/fr.json +82 -2
  28. package/i18n/locales/he.json +82 -2
  29. package/i18n/locales/it.json +82 -2
  30. package/i18n/locales/ja.json +82 -2
  31. package/i18n/locales/pl.json +82 -2
  32. package/i18n/locales/tr.json +82 -2
  33. package/i18n/locales/uk.json +82 -2
  34. package/package.json +2 -2
@@ -0,0 +1,95 @@
1
+ <script setup lang="ts">
2
+ // Pick a linked repository and one of its pull requests (an open one from the synced list, or any
3
+ // number typed in) to open a guided review on.
4
+ import type { OpenGuidedReviewInput } from '~/types/domain'
5
+
6
+ defineProps<{ opening: boolean; loading: boolean }>()
7
+ const emit = defineEmits<{ open: [target: OpenGuidedReviewInput] }>()
8
+ const { t } = useI18n()
9
+ const github = useGitHubStore()
10
+
11
+ const repoId = ref<number | undefined>(undefined)
12
+ const prNumber = ref<number | undefined>(undefined)
13
+
14
+ const repoItems = computed(() =>
15
+ github.repos.map((r) => ({ label: `${r.owner}/${r.name}`, value: r.githubId })),
16
+ )
17
+ const repo = computed(() => github.repos.find((r) => r.githubId === repoId.value))
18
+ const openPulls = computed(() =>
19
+ repo.value ? github.pullsForRepo(repo.value.githubId).filter((p) => p.state === 'open') : [],
20
+ )
21
+
22
+ function submit(number = prNumber.value): void {
23
+ const r = repo.value
24
+ if (!r || !number) return
25
+ emit('open', {
26
+ owner: r.owner,
27
+ repo: r.name,
28
+ prNumber: number,
29
+ ...(r.provider ? { provider: r.provider } : {}),
30
+ })
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div class="mx-auto max-w-xl space-y-4" data-testid="guided-review-picker">
36
+ <p class="text-sm text-muted">{{ t('guidedReview.picker.intro') }}</p>
37
+ <p v-if="loading" class="flex items-center gap-2 text-sm text-muted">
38
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
39
+ {{ t('guidedReview.picker.loading') }}
40
+ </p>
41
+ <p v-else-if="!github.repos.length" class="text-sm text-toned">
42
+ {{ t('guidedReview.picker.noRepos') }}
43
+ </p>
44
+ <template v-else>
45
+ <UFormField :label="t('guidedReview.picker.repo')">
46
+ <USelectMenu
47
+ v-model="repoId"
48
+ :items="repoItems"
49
+ value-key="value"
50
+ class="w-full"
51
+ data-testid="guided-review-picker-repo"
52
+ />
53
+ </UFormField>
54
+ <div v-if="openPulls.length" class="space-y-1">
55
+ <p class="text-xs font-semibold uppercase text-dimmed">
56
+ {{ t('guidedReview.picker.openPulls') }}
57
+ </p>
58
+ <UButton
59
+ v-for="pr in openPulls"
60
+ :key="pr.number"
61
+ block
62
+ size="sm"
63
+ variant="ghost"
64
+ color="neutral"
65
+ class="justify-start"
66
+ :disabled="opening"
67
+ :data-testid="`guided-review-picker-pr-${pr.number}`"
68
+ @click="submit(pr.number)"
69
+ >
70
+ <span class="truncate">#{{ pr.number }} {{ pr.title }}</span>
71
+ </UButton>
72
+ </div>
73
+ <div class="flex items-end gap-2">
74
+ <UFormField :label="t('guidedReview.picker.number')" class="flex-1">
75
+ <UInputNumber
76
+ v-model="prNumber"
77
+ :min="1"
78
+ class="w-full"
79
+ data-testid="guided-review-picker-number"
80
+ />
81
+ </UFormField>
82
+ <UButton
83
+ color="primary"
84
+ icon="i-lucide-scan-search"
85
+ :loading="opening"
86
+ :disabled="!repo || !prNumber"
87
+ data-testid="guided-review-picker-open"
88
+ @click="submit()"
89
+ >
90
+ {{ t('guidedReview.picker.open') }}
91
+ </UButton>
92
+ </div>
93
+ </template>
94
+ </div>
95
+ </template>
@@ -0,0 +1,177 @@
1
+ <script setup lang="ts">
2
+ // One exploration thread: its messages, the comment drafts it produced, and a composer. While its
3
+ // own answer is pending the composer waits; other threads are unaffected.
4
+ import type { GuidedReviewCommentDraft, GuidedReviewThreadView } from '~/types/domain'
5
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
6
+ import GuidedReviewFailure from '~/components/guidedReview/GuidedReviewFailure.vue'
7
+ import {
8
+ canAsk,
9
+ citationLabel,
10
+ draftAnchor,
11
+ droppedAnchor,
12
+ isLive,
13
+ keptDrafts,
14
+ } from '~/components/guidedReview/GuidedReview.logic'
15
+
16
+ const props = defineProps<{
17
+ sessionId: string
18
+ /** Absent for the unsaved new-thread tab: its first question creates the thread. */
19
+ view: GuidedReviewThreadView | undefined
20
+ drafts: GuidedReviewCommentDraft[]
21
+ }>()
22
+ const emit = defineEmits<{ created: [threadId: string] }>()
23
+ const { t } = useI18n()
24
+ const store = useGuidedReviewStore()
25
+ const { present } = usePipelineErrorToast()
26
+
27
+ const prompt = ref('')
28
+ const sending = ref(false)
29
+ const messages = computed(() => props.view?.messages ?? [])
30
+ const ready = computed(() => canAsk(messages.value) && !sending.value)
31
+ const canSend = computed(() => ready.value && prompt.value.trim().length > 0)
32
+
33
+ async function send(): Promise<void> {
34
+ if (!canSend.value) return
35
+ const content = prompt.value.trim()
36
+ sending.value = true
37
+ try {
38
+ if (props.view) {
39
+ await store.ask(props.sessionId, props.view.thread.id, { content })
40
+ } else {
41
+ const created = await store.openThread(props.sessionId, { question: { content } })
42
+ emit('created', created.thread.id)
43
+ }
44
+ prompt.value = ''
45
+ } catch (error) {
46
+ present(error, 'guidedReview.errors.ask')
47
+ } finally {
48
+ sending.value = false
49
+ }
50
+ }
51
+
52
+ async function draftComments(): Promise<void> {
53
+ if (!props.view || !ready.value) return
54
+ sending.value = true
55
+ try {
56
+ await store.requestDrafts(props.sessionId, props.view.thread.id)
57
+ } catch (error) {
58
+ present(error, 'guidedReview.errors.drafts')
59
+ } finally {
60
+ sending.value = false
61
+ }
62
+ }
63
+ </script>
64
+
65
+ <template>
66
+ <div class="flex h-full min-h-0 flex-col gap-3" data-testid="guided-review-thread">
67
+ <div class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
68
+ <p v-if="!messages.length" class="text-sm text-muted">
69
+ {{ t('guidedReview.thread.empty') }}
70
+ </p>
71
+ <div
72
+ v-for="m in messages"
73
+ :key="m.id"
74
+ :class="m.role === 'user' ? 'ms-8 bg-elevated' : 'me-8 border border-default'"
75
+ class="rounded-lg p-3 text-sm"
76
+ :data-testid="`guided-review-message-${m.role}`"
77
+ >
78
+ <template v-if="m.role === 'user'">
79
+ <p v-if="m.kind === 'comment-drafts'" class="text-xs text-dimmed">
80
+ {{ t('guidedReview.thread.draftRequest') }}
81
+ </p>
82
+ <p class="whitespace-pre-wrap">{{ m.content }}</p>
83
+ </template>
84
+ <p v-else-if="isLive(m)" class="flex items-center gap-2 text-muted">
85
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
86
+ {{ t('guidedReview.thread.waiting') }}
87
+ </p>
88
+ <GuidedReviewFailure v-else-if="m.status === 'failed' && m.failure" :failure="m.failure" />
89
+ <template v-else-if="m.kind === 'answer'">
90
+ <MarkdownProse :text="m.content" />
91
+ <div v-if="m.citations.length" class="mt-2 flex flex-wrap gap-1">
92
+ <UBadge
93
+ v-for="(c, i) in m.citations"
94
+ :key="i"
95
+ size="sm"
96
+ variant="outline"
97
+ color="neutral"
98
+ >
99
+ {{ citationLabel(c) }}
100
+ </UBadge>
101
+ </div>
102
+ </template>
103
+ <template v-else>
104
+ <p>
105
+ {{
106
+ t('guidedReview.drafts.summary', {
107
+ kept: keptDrafts(drafts, m.id),
108
+ proposed: m.draftReport?.proposed ?? 0,
109
+ })
110
+ }}
111
+ </p>
112
+ <ul v-if="m.draftReport?.dropped.length" class="mt-1 space-y-0.5 text-xs text-muted">
113
+ <li
114
+ v-for="(d, i) in m.draftReport.dropped"
115
+ :key="i"
116
+ data-testid="guided-review-dropped"
117
+ >
118
+ {{ droppedAnchor(d) }}:
119
+ {{ t(`guidedReview.dropped.${d.reason}`) }}
120
+ </li>
121
+ </ul>
122
+ </template>
123
+ </div>
124
+
125
+ <div v-if="drafts.length" class="space-y-2" data-testid="guided-review-drafts">
126
+ <h4 class="text-xs font-semibold uppercase text-dimmed">
127
+ {{ t('guidedReview.drafts.title') }}
128
+ </h4>
129
+ <div v-for="d in drafts" :key="d.id" class="rounded-md border border-default p-2 text-sm">
130
+ <p class="text-xs text-dimmed">
131
+ {{ draftAnchor(d) }}
132
+ ({{ t(`guidedReview.drafts.side.${d.side}`) }})
133
+ </p>
134
+ <MarkdownProse :text="d.body" />
135
+ <p v-if="d.rationale" class="mt-1 text-xs text-muted">{{ d.rationale }}</p>
136
+ </div>
137
+ </div>
138
+ </div>
139
+
140
+ <div class="space-y-2">
141
+ <UTextarea
142
+ v-model="prompt"
143
+ :rows="3"
144
+ autoresize
145
+ :maxrows="10"
146
+ class="w-full"
147
+ :disabled="!ready"
148
+ :placeholder="ready ? t('guidedReview.thread.placeholder') : t('guidedReview.thread.busy')"
149
+ data-testid="guided-review-composer"
150
+ @keydown.enter.meta.prevent="send"
151
+ @keydown.enter.ctrl.prevent="send"
152
+ />
153
+ <div class="flex flex-wrap items-center gap-2">
154
+ <UButton
155
+ color="primary"
156
+ icon="i-lucide-send"
157
+ :loading="sending"
158
+ :disabled="!canSend"
159
+ data-testid="guided-review-send"
160
+ @click="send"
161
+ >
162
+ {{ t('guidedReview.thread.send') }}
163
+ </UButton>
164
+ <UButton
165
+ v-if="view"
166
+ variant="soft"
167
+ icon="i-lucide-message-square-plus"
168
+ :disabled="!ready || !messages.length"
169
+ data-testid="guided-review-draft-comments"
170
+ @click="draftComments"
171
+ >
172
+ {{ t('guidedReview.drafts.request') }}
173
+ </UButton>
174
+ </div>
175
+ </div>
176
+ </div>
177
+ </template>
@@ -23,9 +23,13 @@ import {
23
23
  reviewQueueDirty,
24
24
  reviewSkillQueuePatch,
25
25
  } from '~/components/panels/inspector/TaskReviewTarget.logic'
26
+ import { reviewTaskTarget } from '~/components/guidedReview/GuidedReview.logic'
26
27
 
27
28
  const props = defineProps<{ block: Block }>()
28
29
  const board = useBoardStore()
30
+ const github = useGitHubStore()
31
+ const ui = useUiStore()
32
+ const access = useWorkspaceAccess()
29
33
  const { t } = useI18n()
30
34
 
31
35
  const isReview = computed(() => props.block.taskType === 'review')
@@ -33,6 +37,26 @@ const fields = computed(() => props.block.taskTypeFields ?? null)
33
37
  const url = computed(() => fields.value?.prUrl?.trim() || '')
34
38
  const focus = computed(() => fields.value?.reviewFocus?.trim() || '')
35
39
 
40
+ // Opening a guided review is a member-tier write, gated like the sidebar entry that opens one.
41
+ const canGuide = computed(() => isReview.value && access.canWriteBoard.value)
42
+
43
+ // Nothing loads the repository projection on board open, and the guided-review button below
44
+ // resolves the task's repository from it.
45
+ watch(
46
+ canGuide,
47
+ (can) => {
48
+ if (can) void github.ensureLoaded().catch(() => {})
49
+ },
50
+ { immediate: true },
51
+ )
52
+
53
+ /** The PR as a guided review opens it, when the task's service has a linked repository. */
54
+ const guidedTarget = computed(() => {
55
+ if (!canGuide.value) return null
56
+ const frame = board.serviceOf(props.block)
57
+ return reviewTaskTarget(fields.value, frame ? github.repoForBlock(frame.id) : undefined)
58
+ })
59
+
36
60
  /** `#123` when the number is known, else the raw link — never an empty affordance. */
37
61
  const label = computed(() => {
38
62
  const number = fields.value?.prNumber
@@ -107,6 +131,18 @@ function revert() {
107
131
  >
108
132
  {{ label }}
109
133
  </p>
134
+ <UButton
135
+ v-if="guidedTarget"
136
+ color="primary"
137
+ variant="soft"
138
+ size="sm"
139
+ icon="i-lucide-scan-search"
140
+ block
141
+ data-testid="inspector-review-target-guided"
142
+ @click="ui.openGuidedReview({ sessionId: null, target: guidedTarget })"
143
+ >
144
+ {{ t('guidedReview.openFromTask') }}
145
+ </UButton>
110
146
  <p v-if="focus" class="text-xs leading-relaxed text-dimmed">
111
147
  {{ t('inspector.reviewTarget.focus', { focus }) }}
112
148
  </p>
@@ -0,0 +1,80 @@
1
+ import {
2
+ askGuidedReviewContract,
3
+ deleteGuidedReviewContract,
4
+ getGuidedReviewContract,
5
+ getGuidedReviewThreadContract,
6
+ listGuidedReviewsContract,
7
+ openGuidedReviewContract,
8
+ openGuidedReviewThreadContract,
9
+ refreshGuidedReviewContract,
10
+ requestGuidedReviewDraftsContract,
11
+ } from '@cat-factory/contracts'
12
+ import type {
13
+ AskGuidedReviewInput,
14
+ OpenGuidedReviewInput,
15
+ OpenGuidedReviewThreadInput,
16
+ } from '~/types/domain'
17
+ import type { ApiContext } from './context'
18
+
19
+ /** Guided PR review: sessions, their threads, questions and comment-draft requests. */
20
+ export function guidedReviewApi({ send, ws }: ApiContext) {
21
+ return {
22
+ openGuidedReview: (workspaceId: string, body: OpenGuidedReviewInput) =>
23
+ send(openGuidedReviewContract, { pathPrefix: ws(workspaceId), body }),
24
+
25
+ listGuidedReviews: (
26
+ workspaceId: string,
27
+ query: { repoId?: string; prNumber?: string; mine?: 'true' | 'false' } = {},
28
+ ) => send(listGuidedReviewsContract, { pathPrefix: ws(workspaceId), queryParams: query }),
29
+
30
+ getGuidedReview: (workspaceId: string, sessionId: string) =>
31
+ send(getGuidedReviewContract, { pathPrefix: ws(workspaceId), pathParams: { sessionId } }),
32
+
33
+ deleteGuidedReview: (workspaceId: string, sessionId: string) =>
34
+ send(deleteGuidedReviewContract, { pathPrefix: ws(workspaceId), pathParams: { sessionId } }),
35
+
36
+ refreshGuidedReview: (workspaceId: string, sessionId: string) =>
37
+ send(refreshGuidedReviewContract, { pathPrefix: ws(workspaceId), pathParams: { sessionId } }),
38
+
39
+ openGuidedReviewThread: (
40
+ workspaceId: string,
41
+ sessionId: string,
42
+ body: OpenGuidedReviewThreadInput,
43
+ ) =>
44
+ send(openGuidedReviewThreadContract, {
45
+ pathPrefix: ws(workspaceId),
46
+ pathParams: { sessionId },
47
+ body,
48
+ }),
49
+
50
+ getGuidedReviewThread: (workspaceId: string, sessionId: string, threadId: string) =>
51
+ send(getGuidedReviewThreadContract, {
52
+ pathPrefix: ws(workspaceId),
53
+ pathParams: { sessionId, threadId },
54
+ }),
55
+
56
+ askGuidedReview: (
57
+ workspaceId: string,
58
+ sessionId: string,
59
+ threadId: string,
60
+ body: AskGuidedReviewInput,
61
+ ) =>
62
+ send(askGuidedReviewContract, {
63
+ pathPrefix: ws(workspaceId),
64
+ pathParams: { sessionId, threadId },
65
+ body,
66
+ }),
67
+
68
+ requestGuidedReviewDrafts: (
69
+ workspaceId: string,
70
+ sessionId: string,
71
+ threadId: string,
72
+ instructions = '',
73
+ ) =>
74
+ send(requestGuidedReviewDraftsContract, {
75
+ pathPrefix: ws(workspaceId),
76
+ pathParams: { sessionId, threadId },
77
+ body: { instructions },
78
+ }),
79
+ }
80
+ }
@@ -29,6 +29,7 @@ import { humanTestApi } from './api/humanTest'
29
29
  import { infraHandlersApi } from './api/infraHandlers'
30
30
  import { initiativeApi } from './api/initiative'
31
31
  import { docInterviewApi } from './api/docInterview'
32
+ import { guidedReviewApi } from './api/guidedReview'
32
33
  import { visualConfirmApi } from './api/visualConfirm'
33
34
  import { kaizenApi } from './api/kaizen'
34
35
  import { localSettingsApi } from './api/localSettings'
@@ -160,6 +161,7 @@ export function useApi() {
160
161
  ...infraHandlersApi(ctx),
161
162
  ...initiativeApi(ctx),
162
163
  ...docInterviewApi(ctx),
164
+ ...guidedReviewApi(ctx),
163
165
  ...provisioningLogsApi(ctx),
164
166
  ...releaseHealthApi(ctx),
165
167
  ...validationChecksApi(ctx),
@@ -41,6 +41,7 @@ export function useNavContributions() {
41
41
  // Consumer items bypass this map entirely via their own `run` closure.
42
42
  const actions: Record<NavActionId, () => void> = {
43
43
  assistant: () => ui.openAssistant(),
44
+ guidedReview: () => ui.openGuidedReview(),
44
45
  buildPipeline: () => ui.openBuilder(),
45
46
  addFromRepo: () => ui.openAddService(),
46
47
  bootstrapRepo: () => ui.openBootstrap(),
@@ -171,6 +171,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
171
171
  titleKey: 'errors.conflict.title.delegated_claim_missing',
172
172
  descriptionKey: 'errors.conflict.description.delegated_claim_missing',
173
173
  },
174
+ thread_busy: {
175
+ titleKey: 'errors.conflict.title.thread_busy',
176
+ descriptionKey: 'errors.conflict.description.thread_busy',
177
+ },
174
178
  task_limit_reached: {
175
179
  titleKey: 'errors.conflict.title.task_limit_reached',
176
180
  descriptionKey: 'errors.conflict.description.task_limit_reached',
@@ -34,6 +34,7 @@ export function useWorkspaceStream() {
34
34
  const kaizen = useKaizenStore()
35
35
  const initiatives = useInitiativesStore()
36
36
  const docInterview = useDocInterviewStore()
37
+ const guidedReview = useGuidedReviewStore()
37
38
  const api = useApi()
38
39
  const apiBase = useRuntimeConfig().public.apiBase
39
40
 
@@ -89,6 +90,7 @@ export function useWorkspaceStream() {
89
90
  upsertKaizen: (g) => kaizen.upsert(g),
90
91
  upsertInitiative: (i) => initiatives.upsert(i),
91
92
  upsertDocInterview: (s) => docInterview.upsert(s),
93
+ guidedReviewChanged: (change) => void guidedReview.applyChange(change),
92
94
  refreshBoard: () => coarse.schedule(),
93
95
  }
94
96
 
@@ -148,6 +150,8 @@ export function useWorkspaceStream() {
148
150
  // failure (`coarse.withRetry`) so a reconnect no longer presents as fully live while
149
151
  // silently missing everything from the outage; `connected` is still set even if every
150
152
  // retry fails (we ARE connected; a refresh error must not wedge the indicator/tests).
153
+ // A guided review is not in the snapshot, so whatever this tab has loaded refetches itself.
154
+ void guidedReview.resync()
151
155
  void coarse.withRetry(workspaceId).finally(() => {
152
156
  // A workspace switch (or stop()) may have happened while the refresh was in
153
157
  // flight — don't announce a connection for a socket we've since abandoned.
@@ -30,6 +30,7 @@ function targets(): WorkspaceEventTargets & { calls: string[] } {
30
30
  upsertKaizen: record('upsertKaizen'),
31
31
  upsertInitiative: record('upsertInitiative'),
32
32
  upsertDocInterview: record('upsertDocInterview'),
33
+ guidedReviewChanged: record('guidedReviewChanged'),
33
34
  refreshBoard: record('refreshBoard'),
34
35
  }
35
36
  }
@@ -129,6 +130,7 @@ describe('applyWorkspaceEvent: the other branches', () => {
129
130
  [{ type: 'kaizen', grading: {} as never, at: 1 }, 'upsertKaizen'],
130
131
  [{ type: 'initiative', initiative: {} as never, at: 1 }, 'upsertInitiative'],
131
132
  [{ type: 'docInterview', session: {} as never, at: 1 }, 'upsertDocInterview'],
133
+ [{ type: 'guidedReview', change: {} as never, at: 1 }, 'guidedReviewChanged'],
132
134
  ]
133
135
 
134
136
  for (const [event, expected] of cases) {
@@ -16,6 +16,7 @@ import type { BrainstormSession } from '~/types/brainstorm'
16
16
  import type { ClarityReview } from '~/types/clarity'
17
17
  import type { ConsensusSession } from '~/types/consensus'
18
18
  import type { DocInterviewSession } from '~/types/doc-interview'
19
+ import type { GuidedReviewChange } from '~/types/guided-review'
19
20
  import type { RequirementReview } from '~/types/requirements'
20
21
 
21
22
  /**
@@ -44,6 +45,8 @@ export interface WorkspaceEventTargets {
44
45
  upsertKaizen: (g: KaizenGrading) => void
45
46
  upsertInitiative: (i: Initiative) => void
46
47
  upsertDocInterview: (s: DocInterviewSession) => void
48
+ /** A guided review moved; the store refetches only what this client has loaded. */
49
+ guidedReviewChanged: (change: GuidedReviewChange) => void
47
50
  /** Debounced full `workspace.refresh()`: the fallback for a change no payload can state. */
48
51
  refreshBoard: () => void
49
52
  }
@@ -151,6 +154,9 @@ export function applyWorkspaceEvent(event: WorkspaceEvent, to: WorkspaceEventTar
151
154
  // convergence): patch the cache so an open interview window reflects it live.
152
155
  to.upsertDocInterview(event.session)
153
156
  return
157
+ case 'guidedReview':
158
+ to.guidedReviewChanged(event.change)
159
+ return
154
160
  default:
155
161
  return dropUnknownEvent(event)
156
162
  }
@@ -197,6 +197,7 @@ export interface NavCommandSpec {
197
197
  */
198
198
  export const NAV_ACTIONS = [
199
199
  'assistant',
200
+ 'guidedReview',
200
201
  'buildPipeline',
201
202
  'addFromRepo',
202
203
  'bootstrapRepo',
@@ -395,6 +396,25 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
395
396
  keywordsKey: 'layout.commandBar.keywords.addFromRepo',
396
397
  },
397
398
  },
399
+ {
400
+ // Guided PR review. Basic tier, because reviewing pull requests is part of the everyday
401
+ // delivery loop; not `intake`, because the intake role brings work in rather than reviewing
402
+ // the code that delivers it.
403
+ id: 'guided-review',
404
+ labelKey: 'nav.guidedReview',
405
+ icon: 'i-lucide-scan-search',
406
+ surfaces: S('sidebar', 'command'),
407
+ gate: (g) => g.githubAvailable && g.canWriteBoard,
408
+ action: 'guidedReview',
409
+ testId: 'nav-guided-review',
410
+ sidebar: { group: 'repositories', order: 15 },
411
+ command: {
412
+ group: 'repositories',
413
+ order: 15,
414
+ labelKey: 'layout.commandBar.cmd.guidedReview',
415
+ keywordsKey: 'layout.commandBar.keywords.guidedReview',
416
+ },
417
+ },
398
418
  {
399
419
  id: 'bootstrap-repo',
400
420
  labelKey: 'nav.bootstrapRepo',
@@ -49,6 +49,9 @@ const TaskSourceConnectModal = defineAsyncView(
49
49
  const TaskImportModal = defineAsyncView(() => import('~/components/tasks/TaskImportModal.vue'))
50
50
  const BugHuntModal = defineAsyncView(() => import('~/components/tasks/BugHuntModal.vue'))
51
51
  const AssistantModal = defineAsyncView(() => import('~/components/assistant/AssistantModal.vue'))
52
+ const GuidedReviewModal = defineAsyncView(
53
+ () => import('~/components/guidedReview/GuidedReviewModal.vue'),
54
+ )
52
55
  const RecurringPipelineModal = defineAsyncView(
53
56
  () => import('~/components/board/RecurringPipelineModal.vue'),
54
57
  )
@@ -503,6 +506,7 @@ watch(
503
506
  <TaskImportModal v-if="ui.taskImport" />
504
507
  <BugHuntModal v-if="ui.bugHunt" />
505
508
  <AssistantModal v-if="ui.assistantOpen" />
509
+ <GuidedReviewModal v-if="ui.guidedReview" />
506
510
  <RecurringPipelineModal v-if="ui.addRecurringFrameId" />
507
511
  <ObservabilityPanel v-if="ui.observabilityInstanceId" />
508
512
  <OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />