@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.
- package/app/components/guidedReview/GuidedReview.logic.spec.ts +123 -0
- package/app/components/guidedReview/GuidedReview.logic.ts +108 -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 +177 -0
- package/app/components/panels/inspector/TaskReviewTarget.vue +36 -0
- package/app/composables/api/guidedReview.ts +80 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useWorkspaceStream.ts +4 -0
- package/app/composables/workspaceStream/applyWorkspaceEvent.spec.ts +2 -0
- package/app/composables/workspaceStream/applyWorkspaceEvent.ts +6 -0
- package/app/modular/nav-contributions.ts +20 -0
- package/app/pages/index.vue +4 -0
- package/app/stores/guidedReview.spec.ts +148 -0
- package/app/stores/guidedReview.ts +200 -0
- package/app/stores/ui/modals.ts +25 -1
- package/app/stores/workspace/hydrate.ts +2 -0
- package/app/types/domain.ts +1 -0
- package/app/types/guided-review.ts +16 -0
- package/i18n/locales/de.json +82 -2
- package/i18n/locales/en.json +82 -2
- package/i18n/locales/es.json +82 -2
- package/i18n/locales/fr.json +82 -2
- package/i18n/locales/he.json +82 -2
- package/i18n/locales/it.json +82 -2
- package/i18n/locales/ja.json +82 -2
- package/i18n/locales/pl.json +82 -2
- package/i18n/locales/tr.json +82 -2
- package/i18n/locales/uk.json +82 -2
- 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',
|
package/app/pages/index.vue
CHANGED
|
@@ -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" />
|