@cat-factory/app 0.304.0 → 0.306.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.
@@ -0,0 +1,243 @@
1
+ <script setup lang="ts">
2
+ // Guided PR review: the overview of a pull request beside tabbed exploration threads. Opened on a
3
+ // session, on a pull request (opening or reusing the caller's session for it), or on neither, in
4
+ // which case it starts with a picker. Live updates arrive through the `guidedReview` event, which
5
+ // the store follows; this window only loads what it shows.
6
+ import type { OpenGuidedReviewInput } from '~/types/domain'
7
+ import GuidedReviewOverview from '~/components/guidedReview/GuidedReviewOverview.vue'
8
+ import GuidedReviewThread from '~/components/guidedReview/GuidedReviewThread.vue'
9
+ import GuidedReviewPicker from '~/components/guidedReview/GuidedReviewPicker.vue'
10
+ import {
11
+ NEW_THREAD_TAB,
12
+ isStale,
13
+ suggestedQuestionThread,
14
+ threadTabs,
15
+ } from '~/components/guidedReview/GuidedReview.logic'
16
+
17
+ const { t } = useI18n()
18
+ const ui = useUiStore()
19
+ const store = useGuidedReviewStore()
20
+ const github = useGitHubStore()
21
+ const { present } = usePipelineErrorToast()
22
+
23
+ const open = computed({
24
+ get: () => ui.guidedReview !== null,
25
+ set: (value: boolean) => {
26
+ if (!value) ui.closeGuidedReview()
27
+ },
28
+ })
29
+
30
+ const sessionId = ref<string | null>(null)
31
+ const opening = ref(false)
32
+ const refreshing = ref(false)
33
+ const asking = ref(false)
34
+ const loadingRepos = ref(false)
35
+ const activeTab = ref<string>(NEW_THREAD_TAB)
36
+ const draftOpen = ref(true)
37
+
38
+ const view = computed(() => (sessionId.value ? store.sessions[sessionId.value] : undefined))
39
+ const session = computed(() => view.value?.session)
40
+ const tabs = computed(() => threadTabs(view.value, draftOpen.value))
41
+ const outOfDate = computed(
42
+ () => !!sessionId.value && store.refetchFailures[sessionId.value] !== undefined,
43
+ )
44
+
45
+ const latestHeadSha = computed(() => {
46
+ const s = session.value
47
+ if (!s) return null
48
+ const repo = github.repos.find((r) => String(r.githubId) === s.repoId)
49
+ const pull = repo ? github.pullsForRepo(repo.githubId).find((p) => p.number === s.prNumber) : null
50
+ return pull?.headSha ?? null
51
+ })
52
+ const stale = computed(
53
+ () => !!session.value && isStale(session.value.reviewedHeadSha, latestHeadSha.value),
54
+ )
55
+
56
+ onModalOpen(open, () => {
57
+ const target = ui.guidedReview
58
+ sessionId.value = null
59
+ draftOpen.value = true
60
+ activeTab.value = NEW_THREAD_TAB
61
+ void loadRepos()
62
+ if (target?.sessionId) void show(target.sessionId)
63
+ else if (target?.target) void openFor(target.target)
64
+ })
65
+
66
+ async function loadRepos(): Promise<void> {
67
+ loadingRepos.value = true
68
+ try {
69
+ await github.ensureLoaded()
70
+ } catch (error) {
71
+ present(error, 'guidedReview.errors.repos')
72
+ } finally {
73
+ loadingRepos.value = false
74
+ }
75
+ }
76
+
77
+ async function show(id: string): Promise<void> {
78
+ sessionId.value = id
79
+ try {
80
+ await store.loadSession(id)
81
+ const first = store.sessions[id]?.threads[0]
82
+ if (first) {
83
+ draftOpen.value = false
84
+ activeTab.value = first.id
85
+ }
86
+ } catch (error) {
87
+ // Back to the picker: with no session to show, the loading state would never end.
88
+ sessionId.value = null
89
+ present(error, 'guidedReview.errors.load')
90
+ }
91
+ }
92
+
93
+ async function openFor(target: OpenGuidedReviewInput): Promise<void> {
94
+ opening.value = true
95
+ try {
96
+ const opened = await store.open(target)
97
+ await show(opened.session.id)
98
+ } catch (error) {
99
+ present(error, 'guidedReview.errors.open')
100
+ } finally {
101
+ opening.value = false
102
+ }
103
+ }
104
+
105
+ watch(activeTab, (tab) => {
106
+ if (sessionId.value && tab !== NEW_THREAD_TAB && !store.threads[tab]) {
107
+ void store
108
+ .loadThread(sessionId.value, tab)
109
+ .catch((error) => present(error, 'guidedReview.errors.load'))
110
+ }
111
+ })
112
+
113
+ function threadTitle(id: string): string {
114
+ if (id === NEW_THREAD_TAB) return t('guidedReview.thread.new')
115
+ return view.value?.threads.find((th) => th.id === id)?.title || t('guidedReview.thread.untitled')
116
+ }
117
+
118
+ function threadBusy(id: string): boolean {
119
+ return !!view.value?.threads.find((th) => th.id === id)?.pendingMessageId
120
+ }
121
+
122
+ function newThread(): void {
123
+ draftOpen.value = true
124
+ activeTab.value = NEW_THREAD_TAB
125
+ }
126
+
127
+ function onCreated(threadId: string): void {
128
+ draftOpen.value = false
129
+ activeTab.value = threadId
130
+ }
131
+
132
+ async function askSuggested(question: string): Promise<void> {
133
+ if (!sessionId.value || asking.value) return
134
+ asking.value = true
135
+ try {
136
+ const created = await store.openThread(sessionId.value, suggestedQuestionThread(question))
137
+ onCreated(created.thread.id)
138
+ } catch (error) {
139
+ present(error, 'guidedReview.errors.ask')
140
+ } finally {
141
+ asking.value = false
142
+ }
143
+ }
144
+
145
+ async function refresh(): Promise<void> {
146
+ if (!sessionId.value) return
147
+ refreshing.value = true
148
+ try {
149
+ await store.refresh(sessionId.value)
150
+ } catch (error) {
151
+ present(error, 'guidedReview.errors.refresh')
152
+ } finally {
153
+ refreshing.value = false
154
+ }
155
+ }
156
+
157
+ const title = computed(() =>
158
+ session.value
159
+ ? t('guidedReview.titleFor', {
160
+ repo: `${session.value.owner}/${session.value.repo}`,
161
+ number: session.value.prNumber,
162
+ title: session.value.prTitle,
163
+ })
164
+ : t('guidedReview.title'),
165
+ )
166
+ </script>
167
+
168
+ <template>
169
+ <UModal v-model:open="open" fullscreen :title="title" data-testid="guided-review-modal">
170
+ <template #body>
171
+ <GuidedReviewPicker
172
+ v-if="!sessionId"
173
+ :opening="opening"
174
+ :loading="loadingRepos"
175
+ @open="openFor"
176
+ />
177
+
178
+ <p v-else-if="!session" class="flex items-center gap-2 text-sm text-muted">
179
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
180
+ {{ t('guidedReview.loading') }}
181
+ </p>
182
+
183
+ <div v-else class="flex h-full min-h-0 flex-col gap-4 lg:flex-row">
184
+ <div class="min-h-0 space-y-3 overflow-y-auto lg:w-2/5 lg:pr-2">
185
+ <p
186
+ v-if="outOfDate"
187
+ class="flex items-center gap-2 text-xs text-warning"
188
+ data-testid="guided-review-out-of-date"
189
+ >
190
+ <UIcon name="i-lucide-triangle-alert" class="h-4 w-4 shrink-0" />
191
+ {{ t('guidedReview.outOfDate') }}
192
+ </p>
193
+ <GuidedReviewOverview
194
+ :session="session"
195
+ :stale="stale"
196
+ :refreshing="refreshing"
197
+ :asking="asking"
198
+ @ask="askSuggested"
199
+ @refresh="refresh"
200
+ />
201
+ </div>
202
+
203
+ <div class="flex min-h-[24rem] flex-1 flex-col gap-3 lg:min-h-0">
204
+ <div class="flex flex-wrap items-center gap-1" role="tablist">
205
+ <UButton
206
+ v-for="tab in tabs"
207
+ :key="tab"
208
+ role="tab"
209
+ size="sm"
210
+ :aria-selected="tab === activeTab"
211
+ :variant="tab === activeTab ? 'soft' : 'ghost'"
212
+ :color="tab === activeTab ? 'primary' : 'neutral'"
213
+ :icon="threadBusy(tab) ? 'i-lucide-loader-circle' : undefined"
214
+ :ui="{ leadingIcon: threadBusy(tab) ? 'animate-spin' : '' }"
215
+ :data-testid="`guided-review-tab-${tab}`"
216
+ @click="activeTab = tab"
217
+ >
218
+ <span class="max-w-48 truncate">{{ threadTitle(tab) }}</span>
219
+ </UButton>
220
+ <UButton
221
+ v-if="!draftOpen"
222
+ size="sm"
223
+ variant="ghost"
224
+ icon="i-lucide-plus"
225
+ :aria-label="t('guidedReview.thread.new')"
226
+ data-testid="guided-review-new-thread"
227
+ @click="newThread"
228
+ />
229
+ </div>
230
+
231
+ <GuidedReviewThread
232
+ :key="activeTab"
233
+ class="min-h-0 flex-1"
234
+ :session-id="session.id"
235
+ :view="activeTab === NEW_THREAD_TAB ? undefined : store.threads[activeTab]"
236
+ :drafts="view?.drafts.filter((d) => d.threadId === activeTab) ?? []"
237
+ @created="onCreated"
238
+ />
239
+ </div>
240
+ </div>
241
+ </template>
242
+ </UModal>
243
+ </template>
@@ -0,0 +1,160 @@
1
+ <script setup lang="ts">
2
+ // The structured explanation of the PR, and the status of producing it. Suggested questions are
3
+ // chips the window turns into new threads.
4
+ import type { GuidedReviewSession } from '~/types/domain'
5
+ import MarkdownProse from '~/components/common/MarkdownProse.vue'
6
+ import GuidedReviewFailure from '~/components/guidedReview/GuidedReviewFailure.vue'
7
+ import { citationLabel } from '~/components/guidedReview/GuidedReview.logic'
8
+
9
+ const props = defineProps<{
10
+ session: GuidedReviewSession
11
+ stale: boolean
12
+ refreshing: boolean
13
+ /** A suggested question is opening its thread; the chips wait so one click makes one thread. */
14
+ asking: boolean
15
+ }>()
16
+ const emit = defineEmits<{ ask: [question: string]; refresh: [] }>()
17
+ const { t } = useI18n()
18
+
19
+ const overview = computed(() => props.session.overview)
20
+ const content = computed(() => overview.value.content)
21
+ const working = computed(
22
+ () => overview.value.status === 'pending' || overview.value.status === 'running',
23
+ )
24
+
25
+ const SEVERITY_COLOR = { high: 'error', medium: 'warning', low: 'neutral' } as const
26
+ </script>
27
+
28
+ <template>
29
+ <section class="space-y-4" data-testid="guided-review-overview">
30
+ <div class="flex flex-wrap items-center gap-2 text-sm">
31
+ <span class="font-medium text-highlighted">
32
+ {{ t('guidedReview.overview.title') }}
33
+ </span>
34
+ <UBadge v-if="stale" color="warning" variant="soft" data-testid="guided-review-stale">
35
+ {{ t('guidedReview.overview.stale') }}
36
+ </UBadge>
37
+ <span class="flex-1" />
38
+ <UButton
39
+ size="xs"
40
+ variant="ghost"
41
+ icon="i-lucide-refresh-cw"
42
+ :loading="refreshing"
43
+ data-testid="guided-review-refresh"
44
+ @click="emit('refresh')"
45
+ >
46
+ {{ t('guidedReview.overview.refresh') }}
47
+ </UButton>
48
+ </div>
49
+
50
+ <p
51
+ v-if="working"
52
+ class="flex items-center gap-2 text-sm text-muted"
53
+ data-testid="guided-review-overview-working"
54
+ >
55
+ <UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
56
+ {{ t('guidedReview.overview.working') }}
57
+ </p>
58
+
59
+ <GuidedReviewFailure
60
+ v-else-if="overview.status === 'failed' && overview.failure"
61
+ :failure="overview.failure"
62
+ />
63
+
64
+ <template v-else-if="content">
65
+ <MarkdownProse :text="content.summary" class="text-sm" />
66
+ <div v-if="content.intent" class="text-sm">
67
+ <h4 class="mb-1 text-xs font-semibold uppercase text-dimmed">
68
+ {{ t('guidedReview.overview.intent') }}
69
+ </h4>
70
+ <MarkdownProse :text="content.intent" />
71
+ </div>
72
+
73
+ <div v-if="content.meaningfulChanges.length" class="space-y-2">
74
+ <h4 class="text-xs font-semibold uppercase text-dimmed">
75
+ {{ t('guidedReview.overview.changes') }}
76
+ </h4>
77
+ <div
78
+ v-for="(change, i) in content.meaningfulChanges"
79
+ :key="`c${i}`"
80
+ class="rounded-md border border-default p-2 text-sm"
81
+ >
82
+ <p class="font-medium">{{ change.title }}</p>
83
+ <MarkdownProse :text="change.detail" class="text-muted" />
84
+ <div class="mt-1 flex flex-wrap gap-1">
85
+ <UBadge v-for="p in change.paths" :key="p" size="sm" variant="outline" color="neutral">
86
+ {{ p }}
87
+ </UBadge>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <div v-if="content.consequences.length" class="space-y-1">
93
+ <h4 class="text-xs font-semibold uppercase text-dimmed">
94
+ {{ t('guidedReview.overview.consequences') }}
95
+ </h4>
96
+ <div v-for="(c, i) in content.consequences" :key="`q${i}`" class="text-sm">
97
+ <span class="font-medium">{{ c.title }}</span>
98
+ <MarkdownProse :text="c.detail" class="text-muted" />
99
+ </div>
100
+ </div>
101
+
102
+ <div v-if="content.risks.length" class="space-y-2">
103
+ <h4 class="text-xs font-semibold uppercase text-dimmed">
104
+ {{ t('guidedReview.overview.risks') }}
105
+ </h4>
106
+ <div v-for="(risk, i) in content.risks" :key="`r${i}`" class="text-sm">
107
+ <div class="flex items-center gap-2">
108
+ <UBadge :color="SEVERITY_COLOR[risk.severity]" variant="soft" size="sm">
109
+ {{ t(`guidedReview.severity.${risk.severity}`) }}
110
+ </UBadge>
111
+ <span class="font-medium">{{ risk.title }}</span>
112
+ </div>
113
+ <MarkdownProse :text="risk.detail" class="text-muted" />
114
+ </div>
115
+ </div>
116
+
117
+ <div v-if="content.focusAreas.length" class="space-y-2">
118
+ <h4 class="text-xs font-semibold uppercase text-dimmed">
119
+ {{ t('guidedReview.overview.focus') }}
120
+ </h4>
121
+ <div v-for="(area, i) in content.focusAreas" :key="`f${i}`" class="text-sm">
122
+ <p class="font-medium">{{ area.title }}</p>
123
+ <MarkdownProse :text="area.why" class="text-muted" />
124
+ <div class="mt-1 flex flex-wrap gap-1">
125
+ <UBadge
126
+ v-for="(a, j) in area.anchors"
127
+ :key="j"
128
+ size="sm"
129
+ variant="outline"
130
+ color="neutral"
131
+ >
132
+ {{ citationLabel(a) }}
133
+ </UBadge>
134
+ </div>
135
+ </div>
136
+ </div>
137
+
138
+ <div v-if="content.suggestedQuestions.length" class="space-y-2">
139
+ <h4 class="text-xs font-semibold uppercase text-dimmed">
140
+ {{ t('guidedReview.overview.suggested') }}
141
+ </h4>
142
+ <div class="flex flex-wrap gap-2">
143
+ <UButton
144
+ v-for="q in content.suggestedQuestions"
145
+ :key="q.id"
146
+ size="xs"
147
+ variant="soft"
148
+ icon="i-lucide-message-circle-question"
149
+ class="text-start"
150
+ :disabled="asking"
151
+ :data-testid="`guided-review-suggested-${q.id}`"
152
+ @click="emit('ask', q.question)"
153
+ >
154
+ {{ q.question }}
155
+ </UButton>
156
+ </div>
157
+ </div>
158
+ </template>
159
+ </section>
160
+ </template>
@@ -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>