@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,123 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ GUIDED_REVIEW_FAILURE_REASONS,
4
+ guidedReviewDroppedDraftReasonSchema,
5
+ } from '@cat-factory/contracts'
6
+ import en from '../../../i18n/locales/en.json'
7
+ import {
8
+ NEW_THREAD_TAB,
9
+ canAsk,
10
+ citationLabel,
11
+ draftAnchor,
12
+ droppedAnchor,
13
+ failureKey,
14
+ keptDrafts,
15
+ isStale,
16
+ reviewTaskTarget,
17
+ suggestedQuestionThread,
18
+ threadTabs,
19
+ } from './GuidedReview.logic'
20
+
21
+ describe('citationLabel', () => {
22
+ it('names a span, a line, or a whole file', () => {
23
+ expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 9 })).toBe('a.ts:3-9')
24
+ expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 3 })).toBe('a.ts:3')
25
+ expect(citationLabel({ path: 'a.ts', startLine: 3 })).toBe('a.ts:3')
26
+ expect(citationLabel({ path: 'a.ts' })).toBe('a.ts')
27
+ })
28
+ })
29
+
30
+ describe('canAsk', () => {
31
+ it('waits only on a live answer in this thread', () => {
32
+ expect(canAsk([{ role: 'user', status: 'complete' }])).toBe(true)
33
+ expect(canAsk([{ role: 'assistant', status: 'pending' }])).toBe(false)
34
+ expect(canAsk([{ role: 'assistant', status: 'running' }])).toBe(false)
35
+ expect(canAsk([{ role: 'assistant', status: 'failed' }])).toBe(true)
36
+ })
37
+ })
38
+
39
+ describe('threadTabs', () => {
40
+ const view = { threads: [{ id: 't1' }, { id: 't2' }] } as never
41
+ it('lists threads oldest first and adds the unsaved tab only while it is open', () => {
42
+ expect(threadTabs(view, false)).toEqual(['t1', 't2'])
43
+ expect(threadTabs(view, true)).toEqual(['t1', 't2', NEW_THREAD_TAB])
44
+ expect(threadTabs(undefined, true)).toEqual([NEW_THREAD_TAB])
45
+ })
46
+ })
47
+
48
+ describe('suggestedQuestionThread', () => {
49
+ it('asks the suggestion in a thread of its own', () => {
50
+ expect(suggestedQuestionThread('Is it bounded?')).toEqual({
51
+ question: { content: 'Is it bounded?' },
52
+ })
53
+ })
54
+ })
55
+
56
+ describe('reviewTaskTarget', () => {
57
+ const repo = { owner: 'acme', name: 'shop', provider: 'gitlab' as const }
58
+ it('prefers the stored number and falls back to the URL', () => {
59
+ expect(reviewTaskTarget({ prNumber: 7 }, repo)).toEqual({
60
+ owner: 'acme',
61
+ repo: 'shop',
62
+ prNumber: 7,
63
+ provider: 'gitlab',
64
+ })
65
+ expect(
66
+ reviewTaskTarget({ prUrl: 'https://gitlab.example/acme/shop/-/merge_requests/42' }, repo)
67
+ ?.prNumber,
68
+ ).toBe(42)
69
+ expect(reviewTaskTarget({ prUrl: 'https://github.com/acme/shop/pull/5' }, repo)?.prNumber).toBe(
70
+ 5,
71
+ )
72
+ })
73
+
74
+ it('is null without a repository or a number', () => {
75
+ expect(reviewTaskTarget({ prNumber: 7 }, undefined)).toBeNull()
76
+ expect(reviewTaskTarget({ prUrl: 'https://example.com/nothing' }, repo)).toBeNull()
77
+ })
78
+
79
+ it('reads the URL the way the dispatch does', () => {
80
+ expect(reviewTaskTarget({ prUrl: 'acme/shop#88' }, repo)?.prNumber).toBe(88)
81
+ expect(
82
+ reviewTaskTarget({ prNumber: 0, prUrl: 'https://github.com/acme/shop/pull/5' }, repo)
83
+ ?.prNumber,
84
+ ).toBe(5)
85
+ })
86
+ })
87
+
88
+ describe('failure and draft labels', () => {
89
+ it('translates each failure reason under its own key', () => {
90
+ expect(failureKey('budget_exhausted')).toBe('guidedReview.failure.budget_exhausted')
91
+ })
92
+
93
+ it('ships copy for every failure and dropped-draft reason the contract can send', () => {
94
+ const catalog = en.guidedReview as unknown as Record<string, Record<string, string>>
95
+ for (const reason of GUIDED_REVIEW_FAILURE_REASONS) {
96
+ expect(catalog.failure![reason], reason).toBeTruthy()
97
+ }
98
+ for (const reason of guidedReviewDroppedDraftReasonSchema.options) {
99
+ expect(catalog.dropped![reason], reason).toBeTruthy()
100
+ }
101
+ })
102
+
103
+ it('names where a draft sits, or would have', () => {
104
+ expect(droppedAnchor({ path: 'a.ts', line: 9 })).toBe('a.ts:9')
105
+ expect(droppedAnchor({ path: '(none)', line: null })).toBe('(none)')
106
+ expect(draftAnchor({ path: 'a.ts', line: 9, startLine: null })).toBe('a.ts:9')
107
+ expect(draftAnchor({ path: 'a.ts', line: 9, startLine: 4 })).toBe('a.ts:4-9')
108
+ })
109
+
110
+ it('counts the drafts one message produced', () => {
111
+ expect(keptDrafts([{ messageId: 'm1' }, { messageId: 'm2' }, { messageId: 'm1' }], 'm1')).toBe(
112
+ 2,
113
+ )
114
+ })
115
+ })
116
+
117
+ describe('isStale', () => {
118
+ it('is stale only when a newer head is known', () => {
119
+ expect(isStale('a', 'b')).toBe(true)
120
+ expect(isStale('a', 'a')).toBe(false)
121
+ expect(isStale('a', null)).toBe(false)
122
+ })
123
+ })
@@ -0,0 +1,108 @@
1
+ import { resolvePrNumber, type GuidedReviewFailureReason } from '@cat-factory/contracts'
2
+ import type {
3
+ GuidedReviewMessage,
4
+ GuidedReviewSessionView,
5
+ OpenGuidedReviewInput,
6
+ OpenGuidedReviewThreadInput,
7
+ } from '~/types/domain'
8
+
9
+ /** A cited span as one short label: `path:12-18`, `path:12`, or the bare path. */
10
+ export function citationLabel(anchor: {
11
+ path: string
12
+ startLine?: number
13
+ endLine?: number
14
+ }): string {
15
+ if (anchor.startLine === undefined) return anchor.path
16
+ if (anchor.endLine === undefined || anchor.endLine === anchor.startLine) {
17
+ return `${anchor.path}:${anchor.startLine}`
18
+ }
19
+ return `${anchor.path}:${anchor.startLine}-${anchor.endLine}`
20
+ }
21
+
22
+ /** A message that is still being produced: the thread is waiting on it. */
23
+ export function isLive(message: Pick<GuidedReviewMessage, 'status'>): boolean {
24
+ return message.status === 'pending' || message.status === 'running'
25
+ }
26
+
27
+ /** Whether a thread may take another question now. Other threads never affect this. */
28
+ export function canAsk(messages: Pick<GuidedReviewMessage, 'status' | 'role'>[]): boolean {
29
+ return !messages.some((m) => m.role === 'assistant' && isLive(m))
30
+ }
31
+
32
+ /**
33
+ * The tab ids the window shows, oldest thread first, then the unsaved new-thread tab when one is
34
+ * open. A new thread is only created on its first question, so an empty tab never reaches the
35
+ * server.
36
+ */
37
+ export const NEW_THREAD_TAB = '__new__'
38
+
39
+ export function threadTabs(
40
+ view: GuidedReviewSessionView | undefined,
41
+ draftOpen: boolean,
42
+ ): string[] {
43
+ const ids = (view?.threads ?? []).map((t) => t.id)
44
+ return draftOpen ? [...ids, NEW_THREAD_TAB] : ids
45
+ }
46
+
47
+ /**
48
+ * What clicking a suggested question does: open a NEW thread asking it. A thread keeps one line
49
+ * of inquiry, so a suggestion never joins the context of whatever tab happens to be active, and a
50
+ * busy tab can never refuse it.
51
+ */
52
+ export function suggestedQuestionThread(question: string): OpenGuidedReviewThreadInput {
53
+ return { question: { content: question } }
54
+ }
55
+
56
+ /**
57
+ * The pull request a `review` task targets, as a guided review opens it: its number from the
58
+ * task fields or its URL, and the repository of the service it sits in. Null when either is
59
+ * unknown.
60
+ */
61
+ export function reviewTaskTarget(
62
+ fields: { prNumber?: number; prUrl?: string } | null | undefined,
63
+ repo: { owner: string; name: string; provider?: 'github' | 'gitlab' } | undefined,
64
+ ): OpenGuidedReviewInput | null {
65
+ if (!repo) return null
66
+ const prNumber = resolvePrNumber(fields)
67
+ if (prNumber === null) return null
68
+ return {
69
+ owner: repo.owner,
70
+ repo: repo.name,
71
+ prNumber,
72
+ ...(repo.provider ? { provider: repo.provider } : {}),
73
+ }
74
+ }
75
+
76
+ /** The catalog key a failure reason is translated under. */
77
+ export function failureKey(reason: GuidedReviewFailureReason): string {
78
+ return `guidedReview.failure.${reason}`
79
+ }
80
+
81
+ /** Where a refused draft would have gone: `path:line`, or the path when it named no line. */
82
+ export function droppedAnchor(dropped: { path: string; line: number | null }): string {
83
+ return dropped.line === null ? dropped.path : `${dropped.path}:${dropped.line}`
84
+ }
85
+
86
+ /** A draft's anchor: `path:line`, or `path:start-line` for a span. */
87
+ export function draftAnchor(draft: {
88
+ path: string
89
+ line: number
90
+ startLine: number | null
91
+ }): string {
92
+ return draft.startLine
93
+ ? `${draft.path}:${draft.startLine}-${draft.line}`
94
+ : `${draft.path}:${draft.line}`
95
+ }
96
+
97
+ /** How many drafts one comment-drafts message produced that are still listed. */
98
+ export function keptDrafts(drafts: { messageId: string }[], messageId: string): number {
99
+ return drafts.filter((d) => d.messageId === messageId).length
100
+ }
101
+
102
+ /** The session is reviewing an older head than the PR's latest known one. */
103
+ export function isStale(
104
+ reviewedHeadSha: string,
105
+ latestHeadSha: string | null | undefined,
106
+ ): boolean {
107
+ return !!latestHeadSha && latestHeadSha !== reviewedHeadSha
108
+ }
@@ -0,0 +1,37 @@
1
+ <script setup lang="ts">
2
+ // A failed overview or answer: the translated reason, with the raw cause behind a disclosure.
3
+ import type { GuidedReviewFailure } from '~/types/domain'
4
+ import { failureKey } from '~/components/guidedReview/GuidedReview.logic'
5
+
6
+ defineProps<{ failure: GuidedReviewFailure }>()
7
+ const { t } = useI18n()
8
+ const expanded = ref(false)
9
+ </script>
10
+
11
+ <template>
12
+ <div
13
+ class="space-y-1 rounded-md bg-elevated/60 p-2 text-sm text-toned"
14
+ data-testid="guided-review-failure"
15
+ >
16
+ <p class="flex items-start gap-2">
17
+ <UIcon name="i-lucide-circle-alert" class="mt-0.5 h-4 w-4 shrink-0 text-error" />
18
+ <span>{{ t(failureKey(failure.reason)) }}</span>
19
+ </p>
20
+ <template v-if="failure.detail">
21
+ <UButton
22
+ size="xs"
23
+ variant="link"
24
+ :icon="expanded ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
25
+ data-testid="guided-review-failure-toggle"
26
+ @click="expanded = !expanded"
27
+ >
28
+ {{ t('guidedReview.failure.detail') }}
29
+ </UButton>
30
+ <pre
31
+ v-if="expanded"
32
+ class="whitespace-pre-wrap break-words text-xs text-muted"
33
+ data-testid="guided-review-failure-detail"
34
+ >{{ failure.detail }}</pre>
35
+ </template>
36
+ </div>
37
+ </template>
@@ -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>