@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.
- package/app/components/guidedReview/GuidedReview.logic.spec.ts +179 -0
- package/app/components/guidedReview/GuidedReview.logic.ts +146 -0
- package/app/components/guidedReview/GuidedReviewDraftCard.vue +145 -0
- package/app/components/guidedReview/GuidedReviewFailure.vue +37 -0
- package/app/components/guidedReview/GuidedReviewModal.vue +243 -0
- package/app/components/guidedReview/GuidedReviewOverview.vue +160 -0
- package/app/components/guidedReview/GuidedReviewPicker.vue +95 -0
- package/app/components/guidedReview/GuidedReviewThread.vue +254 -0
- package/app/components/panels/inspector/TaskReviewTarget.vue +36 -0
- package/app/composables/api/guidedReview.ts +27 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/modular/nav-contributions.ts +20 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/guidedReview.ts +28 -0
- package/app/stores/ui/modals.ts +25 -1
- package/app/types/guided-review.ts +5 -0
- package/i18n/locales/de.json +105 -2
- package/i18n/locales/en.json +105 -2
- package/i18n/locales/es.json +105 -2
- package/i18n/locales/fr.json +105 -2
- package/i18n/locales/he.json +105 -2
- package/i18n/locales/it.json +105 -2
- package/i18n/locales/ja.json +105 -2
- package/i18n/locales/pl.json +105 -2
- package/i18n/locales/tr.json +105 -2
- package/i18n/locales/uk.json +105 -2
- package/package.json +2 -2
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { GUIDED_REVIEW_POST_LEASE_MS } from '@cat-factory/contracts'
|
|
3
|
+
import {
|
|
4
|
+
GUIDED_REVIEW_FAILURE_REASONS,
|
|
5
|
+
guidedReviewDroppedDraftReasonSchema,
|
|
6
|
+
} from '@cat-factory/contracts'
|
|
7
|
+
import en from '../../../i18n/locales/en.json'
|
|
8
|
+
import {
|
|
9
|
+
NEW_THREAD_TAB,
|
|
10
|
+
draftEdit,
|
|
11
|
+
isEditableDraft,
|
|
12
|
+
postableSelection,
|
|
13
|
+
canAsk,
|
|
14
|
+
citationLabel,
|
|
15
|
+
draftAnchor,
|
|
16
|
+
droppedAnchor,
|
|
17
|
+
failureKey,
|
|
18
|
+
keptDrafts,
|
|
19
|
+
isStale,
|
|
20
|
+
reviewTaskTarget,
|
|
21
|
+
suggestedQuestionThread,
|
|
22
|
+
threadTabs,
|
|
23
|
+
} from './GuidedReview.logic'
|
|
24
|
+
|
|
25
|
+
describe('citationLabel', () => {
|
|
26
|
+
it('names a span, a line, or a whole file', () => {
|
|
27
|
+
expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 9 })).toBe('a.ts:3-9')
|
|
28
|
+
expect(citationLabel({ path: 'a.ts', startLine: 3, endLine: 3 })).toBe('a.ts:3')
|
|
29
|
+
expect(citationLabel({ path: 'a.ts', startLine: 3 })).toBe('a.ts:3')
|
|
30
|
+
expect(citationLabel({ path: 'a.ts' })).toBe('a.ts')
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe('canAsk', () => {
|
|
35
|
+
it('waits only on a live answer in this thread', () => {
|
|
36
|
+
expect(canAsk([{ role: 'user', status: 'complete' }])).toBe(true)
|
|
37
|
+
expect(canAsk([{ role: 'assistant', status: 'pending' }])).toBe(false)
|
|
38
|
+
expect(canAsk([{ role: 'assistant', status: 'running' }])).toBe(false)
|
|
39
|
+
expect(canAsk([{ role: 'assistant', status: 'failed' }])).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('threadTabs', () => {
|
|
44
|
+
const view = { threads: [{ id: 't1' }, { id: 't2' }] } as never
|
|
45
|
+
it('lists threads oldest first and adds the unsaved tab only while it is open', () => {
|
|
46
|
+
expect(threadTabs(view, false)).toEqual(['t1', 't2'])
|
|
47
|
+
expect(threadTabs(view, true)).toEqual(['t1', 't2', NEW_THREAD_TAB])
|
|
48
|
+
expect(threadTabs(undefined, true)).toEqual([NEW_THREAD_TAB])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('suggestedQuestionThread', () => {
|
|
53
|
+
it('asks the suggestion in a thread of its own', () => {
|
|
54
|
+
expect(suggestedQuestionThread('Is it bounded?')).toEqual({
|
|
55
|
+
question: { content: 'Is it bounded?' },
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('reviewTaskTarget', () => {
|
|
61
|
+
const repo = { owner: 'acme', name: 'shop', provider: 'gitlab' as const }
|
|
62
|
+
it('prefers the stored number and falls back to the URL', () => {
|
|
63
|
+
expect(reviewTaskTarget({ prNumber: 7 }, repo)).toEqual({
|
|
64
|
+
owner: 'acme',
|
|
65
|
+
repo: 'shop',
|
|
66
|
+
prNumber: 7,
|
|
67
|
+
provider: 'gitlab',
|
|
68
|
+
})
|
|
69
|
+
expect(
|
|
70
|
+
reviewTaskTarget({ prUrl: 'https://gitlab.example/acme/shop/-/merge_requests/42' }, repo)
|
|
71
|
+
?.prNumber,
|
|
72
|
+
).toBe(42)
|
|
73
|
+
expect(reviewTaskTarget({ prUrl: 'https://github.com/acme/shop/pull/5' }, repo)?.prNumber).toBe(
|
|
74
|
+
5,
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('is null without a repository or a number', () => {
|
|
79
|
+
expect(reviewTaskTarget({ prNumber: 7 }, undefined)).toBeNull()
|
|
80
|
+
expect(reviewTaskTarget({ prUrl: 'https://example.com/nothing' }, repo)).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('reads the URL the way the dispatch does', () => {
|
|
84
|
+
expect(reviewTaskTarget({ prUrl: 'acme/shop#88' }, repo)?.prNumber).toBe(88)
|
|
85
|
+
expect(
|
|
86
|
+
reviewTaskTarget({ prNumber: 0, prUrl: 'https://github.com/acme/shop/pull/5' }, repo)
|
|
87
|
+
?.prNumber,
|
|
88
|
+
).toBe(5)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('failure and draft labels', () => {
|
|
93
|
+
it('translates each failure reason under its own key', () => {
|
|
94
|
+
expect(failureKey('budget_exhausted')).toBe('guidedReview.failure.budget_exhausted')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('ships copy for every failure and dropped-draft reason the contract can send', () => {
|
|
98
|
+
const catalog = en.guidedReview as unknown as Record<string, Record<string, string>>
|
|
99
|
+
for (const reason of GUIDED_REVIEW_FAILURE_REASONS) {
|
|
100
|
+
expect(catalog.failure![reason], reason).toBeTruthy()
|
|
101
|
+
}
|
|
102
|
+
for (const reason of guidedReviewDroppedDraftReasonSchema.options) {
|
|
103
|
+
expect(catalog.dropped![reason], reason).toBeTruthy()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('names where a draft sits, or would have', () => {
|
|
108
|
+
expect(droppedAnchor({ path: 'a.ts', line: 9 })).toBe('a.ts:9')
|
|
109
|
+
expect(droppedAnchor({ path: '(none)', line: null })).toBe('(none)')
|
|
110
|
+
expect(draftAnchor({ path: 'a.ts', line: 9, startLine: null })).toBe('a.ts:9')
|
|
111
|
+
expect(draftAnchor({ path: 'a.ts', line: 9, startLine: 4 })).toBe('a.ts:4-9')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('counts the drafts one message produced', () => {
|
|
115
|
+
expect(keptDrafts([{ messageId: 'm1' }, { messageId: 'm2' }, { messageId: 'm1' }], 'm1')).toBe(
|
|
116
|
+
2,
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('isStale', () => {
|
|
122
|
+
it('is stale only when a newer head is known', () => {
|
|
123
|
+
expect(isStale('a', 'b')).toBe(true)
|
|
124
|
+
expect(isStale('a', 'a')).toBe(false)
|
|
125
|
+
expect(isStale('a', null)).toBe(false)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('draft editing and posting', () => {
|
|
130
|
+
it('treats proposed and failed drafts as still editable', () => {
|
|
131
|
+
expect(
|
|
132
|
+
['proposed', 'failed', 'posting', 'posted', 'discarded'].map((status) =>
|
|
133
|
+
isEditableDraft({ status: status as never }),
|
|
134
|
+
),
|
|
135
|
+
).toEqual([true, true, false, false, false])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('posts only the selected drafts that are still postable, in list order', () => {
|
|
139
|
+
const drafts = [
|
|
140
|
+
{ id: 'a', status: 'proposed' as const, updatedAt: 0 },
|
|
141
|
+
{ id: 'b', status: 'posted' as const, updatedAt: 0 },
|
|
142
|
+
{ id: 'c', status: 'failed' as const, updatedAt: 0 },
|
|
143
|
+
]
|
|
144
|
+
expect(postableSelection(drafts, new Set(['c', 'b', 'a']), 1)).toEqual(['a', 'c'])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('offers a posting draft again only once its claim outlives the lease', () => {
|
|
148
|
+
const stranded = [{ id: 'p', status: 'posting' as const, updatedAt: 1_000 }]
|
|
149
|
+
const selected = new Set(['p'])
|
|
150
|
+
expect(postableSelection(stranded, selected, 1_000 + GUIDED_REVIEW_POST_LEASE_MS)).toEqual([])
|
|
151
|
+
expect(postableSelection(stranded, selected, 1_001 + GUIDED_REVIEW_POST_LEASE_MS)).toEqual([
|
|
152
|
+
'p',
|
|
153
|
+
])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('sends only what an edit changed, and nothing for an untouched form', () => {
|
|
157
|
+
const draft = { body: 'Bound the retries.', line: 3, startLine: null, side: 'RIGHT' as const }
|
|
158
|
+
expect(draftEdit(draft, { body: ' Bound the retries. ', line: 3, side: 'RIGHT' })).toEqual({})
|
|
159
|
+
expect(draftEdit(draft, { body: 'Cap at three.', line: 4, side: 'RIGHT' })).toEqual({
|
|
160
|
+
body: 'Cap at three.',
|
|
161
|
+
line: 4,
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('drops the start of a multi-line draft only when the edit moves it', () => {
|
|
166
|
+
const span = { body: 'Span.', line: 8, startLine: 5, side: 'RIGHT' as const }
|
|
167
|
+
expect(draftEdit(span, { body: 'Reworded.', line: 8, side: 'RIGHT' })).toEqual({
|
|
168
|
+
body: 'Reworded.',
|
|
169
|
+
})
|
|
170
|
+
expect(draftEdit(span, { body: 'Span.', line: 3, side: 'RIGHT' })).toEqual({
|
|
171
|
+
line: 3,
|
|
172
|
+
startLine: null,
|
|
173
|
+
})
|
|
174
|
+
expect(draftEdit(span, { body: 'Span.', line: 8, side: 'LEFT' })).toEqual({
|
|
175
|
+
startLine: null,
|
|
176
|
+
side: 'LEFT',
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPostableDraft,
|
|
3
|
+
resolvePrNumber,
|
|
4
|
+
type GuidedReviewFailureReason,
|
|
5
|
+
} from '@cat-factory/contracts'
|
|
6
|
+
import type {
|
|
7
|
+
GuidedReviewCommentDraft,
|
|
8
|
+
GuidedReviewMessage,
|
|
9
|
+
GuidedReviewSessionView,
|
|
10
|
+
OpenGuidedReviewInput,
|
|
11
|
+
OpenGuidedReviewThreadInput,
|
|
12
|
+
} from '~/types/domain'
|
|
13
|
+
|
|
14
|
+
/** A cited span as one short label: `path:12-18`, `path:12`, or the bare path. */
|
|
15
|
+
export function citationLabel(anchor: {
|
|
16
|
+
path: string
|
|
17
|
+
startLine?: number
|
|
18
|
+
endLine?: number
|
|
19
|
+
}): string {
|
|
20
|
+
if (anchor.startLine === undefined) return anchor.path
|
|
21
|
+
if (anchor.endLine === undefined || anchor.endLine === anchor.startLine) {
|
|
22
|
+
return `${anchor.path}:${anchor.startLine}`
|
|
23
|
+
}
|
|
24
|
+
return `${anchor.path}:${anchor.startLine}-${anchor.endLine}`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A message that is still being produced: the thread is waiting on it. */
|
|
28
|
+
export function isLive(message: Pick<GuidedReviewMessage, 'status'>): boolean {
|
|
29
|
+
return message.status === 'pending' || message.status === 'running'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whether a thread may take another question now. Other threads never affect this. */
|
|
33
|
+
export function canAsk(messages: Pick<GuidedReviewMessage, 'status' | 'role'>[]): boolean {
|
|
34
|
+
return !messages.some((m) => m.role === 'assistant' && isLive(m))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The tab ids the window shows, oldest thread first, then the unsaved new-thread tab when one is
|
|
39
|
+
* open. A new thread is only created on its first question, so an empty tab never reaches the
|
|
40
|
+
* server.
|
|
41
|
+
*/
|
|
42
|
+
export const NEW_THREAD_TAB = '__new__'
|
|
43
|
+
|
|
44
|
+
export function threadTabs(
|
|
45
|
+
view: GuidedReviewSessionView | undefined,
|
|
46
|
+
draftOpen: boolean,
|
|
47
|
+
): string[] {
|
|
48
|
+
const ids = (view?.threads ?? []).map((t) => t.id)
|
|
49
|
+
return draftOpen ? [...ids, NEW_THREAD_TAB] : ids
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* What clicking a suggested question does: open a NEW thread asking it. A thread keeps one line
|
|
54
|
+
* of inquiry, so a suggestion never joins the context of whatever tab happens to be active, and a
|
|
55
|
+
* busy tab can never refuse it.
|
|
56
|
+
*/
|
|
57
|
+
export function suggestedQuestionThread(question: string): OpenGuidedReviewThreadInput {
|
|
58
|
+
return { question: { content: question } }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The pull request a `review` task targets, as a guided review opens it: its number from the
|
|
63
|
+
* task fields or its URL, and the repository of the service it sits in. Null when either is
|
|
64
|
+
* unknown.
|
|
65
|
+
*/
|
|
66
|
+
export function reviewTaskTarget(
|
|
67
|
+
fields: { prNumber?: number; prUrl?: string } | null | undefined,
|
|
68
|
+
repo: { owner: string; name: string; provider?: 'github' | 'gitlab' } | undefined,
|
|
69
|
+
): OpenGuidedReviewInput | null {
|
|
70
|
+
if (!repo) return null
|
|
71
|
+
const prNumber = resolvePrNumber(fields)
|
|
72
|
+
if (prNumber === null) return null
|
|
73
|
+
return {
|
|
74
|
+
owner: repo.owner,
|
|
75
|
+
repo: repo.name,
|
|
76
|
+
prNumber,
|
|
77
|
+
...(repo.provider ? { provider: repo.provider } : {}),
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The catalog key a failure reason is translated under. */
|
|
82
|
+
export function failureKey(reason: GuidedReviewFailureReason): string {
|
|
83
|
+
return `guidedReview.failure.${reason}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Where a refused draft would have gone: `path:line`, or the path when it named no line. */
|
|
87
|
+
export function droppedAnchor(dropped: { path: string; line: number | null }): string {
|
|
88
|
+
return dropped.line === null ? dropped.path : `${dropped.path}:${dropped.line}`
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A draft's anchor: `path:line`, or `path:start-line` for a span. */
|
|
92
|
+
export function draftAnchor(draft: {
|
|
93
|
+
path: string
|
|
94
|
+
line: number
|
|
95
|
+
startLine: number | null
|
|
96
|
+
}): string {
|
|
97
|
+
return draft.startLine
|
|
98
|
+
? `${draft.path}:${draft.startLine}-${draft.line}`
|
|
99
|
+
: `${draft.path}:${draft.line}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** How many drafts one comment-drafts message produced that are still listed. */
|
|
103
|
+
export function keptDrafts(drafts: { messageId: string }[], messageId: string): number {
|
|
104
|
+
return drafts.filter((d) => d.messageId === messageId).length
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The session is reviewing an older head than the PR's latest known one. */
|
|
108
|
+
export function isStale(
|
|
109
|
+
reviewedHeadSha: string,
|
|
110
|
+
latestHeadSha: string | null | undefined,
|
|
111
|
+
): boolean {
|
|
112
|
+
return !!latestHeadSha && latestHeadSha !== reviewedHeadSha
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** A draft the reviewer may still edit, discard or post: `proposed`, or a `failed` post. */
|
|
116
|
+
export function isEditableDraft(draft: Pick<GuidedReviewCommentDraft, 'status'>): boolean {
|
|
117
|
+
return draft.status === 'proposed' || draft.status === 'failed'
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The selected drafts a post may claim at `now`, in the order they are listed. */
|
|
121
|
+
export function postableSelection(
|
|
122
|
+
drafts: Pick<GuidedReviewCommentDraft, 'id' | 'status' | 'updatedAt'>[],
|
|
123
|
+
selected: ReadonlySet<string>,
|
|
124
|
+
now: number,
|
|
125
|
+
): string[] {
|
|
126
|
+
return drafts.filter((d) => selected.has(d.id) && isPostableDraft(d, now)).map((d) => d.id)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The fields an inline edit actually changed, against the draft as loaded. Empty when nothing
|
|
131
|
+
* moved, so the editor saves nothing rather than bumping the draft's revision for no change. The
|
|
132
|
+
* editor places a single line, so moving a multi-line draft drops its start: the server would
|
|
133
|
+
* otherwise keep the old start against the new line and refuse the span.
|
|
134
|
+
*/
|
|
135
|
+
export function draftEdit(
|
|
136
|
+
draft: Pick<GuidedReviewCommentDraft, 'body' | 'line' | 'startLine' | 'side'>,
|
|
137
|
+
form: { body: string; line: number; side: 'LEFT' | 'RIGHT' },
|
|
138
|
+
): { body?: string; line?: number; startLine?: null; side?: 'LEFT' | 'RIGHT' } {
|
|
139
|
+
const moved = form.line !== draft.line || form.side !== draft.side
|
|
140
|
+
return {
|
|
141
|
+
...(form.body.trim() !== draft.body ? { body: form.body.trim() } : {}),
|
|
142
|
+
...(form.line !== draft.line ? { line: form.line } : {}),
|
|
143
|
+
...(moved && draft.startLine !== null ? { startLine: null } : {}),
|
|
144
|
+
...(form.side !== draft.side ? { side: form.side } : {}),
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// One comment draft: its anchor, body and rationale, with selection for posting and an inline
|
|
3
|
+
// editor. Only a `proposed` or `failed` draft is editable, though a `posting` one whose poster died
|
|
4
|
+
// can be selected for posting again; the server refuses a stale edit as
|
|
5
|
+
// `draft_conflict` and a line outside the diff, both reported through the error funnel.
|
|
6
|
+
import type { GuidedReviewCommentDraft } from '~/types/domain'
|
|
7
|
+
import MarkdownProse from '~/components/common/MarkdownProse.vue'
|
|
8
|
+
import {
|
|
9
|
+
draftAnchor,
|
|
10
|
+
draftEdit,
|
|
11
|
+
isEditableDraft,
|
|
12
|
+
} from '~/components/guidedReview/GuidedReview.logic'
|
|
13
|
+
|
|
14
|
+
const props = defineProps<{
|
|
15
|
+
sessionId: string
|
|
16
|
+
draft: GuidedReviewCommentDraft
|
|
17
|
+
selected: boolean
|
|
18
|
+
/** Whether a post may claim this draft now, including a stranded `posting` one. */
|
|
19
|
+
postable: boolean
|
|
20
|
+
}>()
|
|
21
|
+
const emit = defineEmits<{ 'update:selected': [value: boolean] }>()
|
|
22
|
+
const { t } = useI18n()
|
|
23
|
+
const store = useGuidedReviewStore()
|
|
24
|
+
const { present } = usePipelineErrorToast()
|
|
25
|
+
|
|
26
|
+
const editing = ref(false)
|
|
27
|
+
const saving = ref(false)
|
|
28
|
+
const form = reactive({ body: '', line: 1, side: 'RIGHT' as 'LEFT' | 'RIGHT' })
|
|
29
|
+
const editable = computed(() => isEditableDraft(props.draft))
|
|
30
|
+
const STATUS_COLOR = {
|
|
31
|
+
proposed: 'neutral',
|
|
32
|
+
posting: 'info',
|
|
33
|
+
posted: 'success',
|
|
34
|
+
failed: 'error',
|
|
35
|
+
discarded: 'neutral',
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
function startEdit(): void {
|
|
39
|
+
form.body = props.draft.body
|
|
40
|
+
form.line = props.draft.line
|
|
41
|
+
form.side = props.draft.side
|
|
42
|
+
editing.value = true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function save(): Promise<void> {
|
|
46
|
+
const changes = draftEdit(props.draft, form)
|
|
47
|
+
if (Object.keys(changes).length === 0) {
|
|
48
|
+
editing.value = false
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
saving.value = true
|
|
52
|
+
try {
|
|
53
|
+
await store.editDraft(props.sessionId, props.draft.id, { rev: props.draft.rev, ...changes })
|
|
54
|
+
editing.value = false
|
|
55
|
+
} catch (error) {
|
|
56
|
+
present(error, 'guidedReview.errors.editDraft')
|
|
57
|
+
} finally {
|
|
58
|
+
saving.value = false
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function discard(): Promise<void> {
|
|
63
|
+
saving.value = true
|
|
64
|
+
try {
|
|
65
|
+
await store.editDraft(props.sessionId, props.draft.id, { rev: props.draft.rev, discard: true })
|
|
66
|
+
emit('update:selected', false)
|
|
67
|
+
} catch (error) {
|
|
68
|
+
present(error, 'guidedReview.errors.editDraft')
|
|
69
|
+
} finally {
|
|
70
|
+
saving.value = false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<div
|
|
77
|
+
class="rounded-md border border-default p-2 text-sm"
|
|
78
|
+
:class="{ 'opacity-60': draft.status === 'discarded' }"
|
|
79
|
+
data-testid="guided-review-draft"
|
|
80
|
+
>
|
|
81
|
+
<div class="flex items-center gap-2">
|
|
82
|
+
<UCheckbox
|
|
83
|
+
v-if="postable"
|
|
84
|
+
:model-value="selected"
|
|
85
|
+
:aria-label="t('guidedReview.drafts.select')"
|
|
86
|
+
@update:model-value="emit('update:selected', $event === true)"
|
|
87
|
+
/>
|
|
88
|
+
<p class="flex-1 text-xs text-dimmed">
|
|
89
|
+
{{ draftAnchor(draft) }} ({{ t(`guidedReview.drafts.side.${draft.side}`) }})
|
|
90
|
+
</p>
|
|
91
|
+
<UBadge size="sm" variant="subtle" :color="STATUS_COLOR[draft.status]">
|
|
92
|
+
{{ t(`guidedReview.drafts.status.${draft.status}`) }}
|
|
93
|
+
</UBadge>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<div v-if="editing" class="mt-2 space-y-2">
|
|
97
|
+
<UTextarea v-model="form.body" :rows="3" autoresize class="w-full" />
|
|
98
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
99
|
+
<UInputNumber
|
|
100
|
+
v-model="form.line"
|
|
101
|
+
:min="1"
|
|
102
|
+
size="sm"
|
|
103
|
+
:aria-label="t('guidedReview.drafts.line')"
|
|
104
|
+
/>
|
|
105
|
+
<USelect
|
|
106
|
+
v-model="form.side"
|
|
107
|
+
size="sm"
|
|
108
|
+
:items="[
|
|
109
|
+
{ label: t('guidedReview.drafts.side.RIGHT'), value: 'RIGHT' },
|
|
110
|
+
{ label: t('guidedReview.drafts.side.LEFT'), value: 'LEFT' },
|
|
111
|
+
]"
|
|
112
|
+
:aria-label="t('guidedReview.drafts.sideLabel')"
|
|
113
|
+
/>
|
|
114
|
+
<UButton size="sm" color="primary" :loading="saving" @click="save">
|
|
115
|
+
{{ t('guidedReview.drafts.save') }}
|
|
116
|
+
</UButton>
|
|
117
|
+
<UButton size="sm" variant="ghost" :disabled="saving" @click="editing = false">
|
|
118
|
+
{{ t('guidedReview.drafts.cancel') }}
|
|
119
|
+
</UButton>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<template v-else>
|
|
123
|
+
<MarkdownProse class="mt-1" :text="draft.body" />
|
|
124
|
+
<p v-if="draft.rationale" class="mt-1 text-xs text-muted">{{ draft.rationale }}</p>
|
|
125
|
+
<p v-if="draft.status === 'failed' && draft.postError" class="mt-1 text-xs text-error">
|
|
126
|
+
{{ t('guidedReview.drafts.postFailed') }}: {{ draft.postError }}
|
|
127
|
+
</p>
|
|
128
|
+
<div v-if="editable" class="mt-2 flex gap-2">
|
|
129
|
+
<UButton size="xs" variant="ghost" icon="i-lucide-pencil" @click="startEdit">
|
|
130
|
+
{{ t('guidedReview.drafts.edit') }}
|
|
131
|
+
</UButton>
|
|
132
|
+
<UButton
|
|
133
|
+
size="xs"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
color="neutral"
|
|
136
|
+
icon="i-lucide-trash-2"
|
|
137
|
+
:loading="saving"
|
|
138
|
+
@click="discard"
|
|
139
|
+
>
|
|
140
|
+
{{ t('guidedReview.drafts.discard') }}
|
|
141
|
+
</UButton>
|
|
142
|
+
</div>
|
|
143
|
+
</template>
|
|
144
|
+
</div>
|
|
145
|
+
</template>
|
|
@@ -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>
|