@cat-factory/app 0.162.2 → 0.164.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/README.md +4 -1
  2. package/app/components/board/RecurringPipelineModal.vue +9 -22
  3. package/app/components/focus/BlockFocusView.vue +25 -19
  4. package/app/components/layout/CommandBar.vue +8 -0
  5. package/app/components/layout/IntegrationsHub.vue +7 -0
  6. package/app/components/panels/InspectorPanel.vue +13 -0
  7. package/app/components/pipeline/PipelinePicker.vue +38 -16
  8. package/app/components/pipeline/PipelinePreview.vue +65 -23
  9. package/app/components/tasks/BugHuntModal.vue +361 -0
  10. package/app/components/tasks/ContextIssuePicker.logic.spec.ts +89 -0
  11. package/app/components/tasks/ContextIssuePicker.logic.ts +74 -0
  12. package/app/components/tasks/ContextIssuePicker.vue +71 -21
  13. package/app/components/tasks/TaskImportModal.vue +2 -4
  14. package/app/composables/api/bugHunt.ts +40 -0
  15. package/app/composables/useApi.ts +2 -0
  16. package/app/pages/index.vue +2 -0
  17. package/app/stores/bugHunt.spec.ts +103 -0
  18. package/app/stores/bugHunt.ts +148 -0
  19. package/app/stores/ui/modals.ts +14 -0
  20. package/app/types/bugHunt.ts +21 -0
  21. package/app/types/domain.ts +1 -0
  22. package/app/utils/pipeline.spec.ts +41 -1
  23. package/app/utils/pipeline.ts +9 -0
  24. package/i18n/locales/de.json +61 -5
  25. package/i18n/locales/en.json +64 -5
  26. package/i18n/locales/es.json +61 -5
  27. package/i18n/locales/fr.json +61 -5
  28. package/i18n/locales/he.json +61 -5
  29. package/i18n/locales/it.json +61 -5
  30. package/i18n/locales/ja.json +61 -5
  31. package/i18n/locales/pl.json +61 -5
  32. package/i18n/locales/tr.json +61 -5
  33. package/i18n/locales/uk.json +61 -5
  34. package/package.json +2 -2
@@ -0,0 +1,361 @@
1
+ <script setup lang="ts">
2
+ // Bug hunt: pick a connected tracker, pick one of its boards, and let the platform rank that
3
+ // board's open, UNASSIGNED bugs by impact against implementation complexity. Confirming a
4
+ // candidate adopts it as a bug task in the chosen container and starts the bug-fix pipeline.
5
+ //
6
+ // The interactive dual of the recurring bug-triage schedule: same reading and same pipeline,
7
+ // but a human picks the bug instead of the oldest match being claimed unattended.
8
+ //
9
+ // Two states this deliberately renders rather than hides: a board whose ranking was
10
+ // unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
11
+ // useful on its own), and a scan that hit its cap says so — a silently shortened list reads
12
+ // exactly like an exhaustive one.
13
+ import type {
14
+ BugHuntAnalysisStatus,
15
+ BugHuntCandidate,
16
+ BugHuntConfidence,
17
+ TaskSourceKind,
18
+ } from '~/types/domain'
19
+ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
20
+
21
+ const { t, d, n } = useI18n()
22
+ const ui = useUiStore()
23
+ const tasks = useTasksStore()
24
+ const board = useBoardStore()
25
+ const hunt = useBugHuntStore()
26
+ const toast = useToast()
27
+
28
+ const open = computed({
29
+ get: () => ui.bugHunt !== null,
30
+ set: (v: boolean) => {
31
+ if (!v) ui.closeBugHunt()
32
+ },
33
+ })
34
+ const back = useIntegrationBack(open)
35
+
36
+ const source = ref<TaskSourceKind | undefined>(undefined)
37
+ const boardId = ref('')
38
+ const issueType = ref('')
39
+ const labels = ref('')
40
+ const containerId = ref<string | undefined>(undefined)
41
+
42
+ // Containers an adopted bug can land in: every service frame and module on the board. Modules
43
+ // are labelled with their parent frame so the choice is unambiguous (same as the import modal).
44
+ const containerItems = computed(() =>
45
+ board.blocks
46
+ .filter((b) => b.level === 'frame' || b.level === 'module')
47
+ .map((b) => ({
48
+ label:
49
+ b.level === 'module'
50
+ ? `${board.getBlock(b.parentId ?? '')?.title ?? '?'} › ${b.title}`
51
+ : b.title,
52
+ value: b.id,
53
+ })),
54
+ )
55
+
56
+ const sourceItems = computed(() =>
57
+ tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
58
+ )
59
+
60
+ const boardItems = computed(() =>
61
+ hunt.boards.map((b) => ({
62
+ label: b.key && b.key !== b.name ? `${b.name} (${b.key})` : b.name,
63
+ value: b.id,
64
+ })),
65
+ )
66
+
67
+ /**
68
+ * The tracker CANNOT enumerate boards, so the user types the scope in themselves. Keyed on the
69
+ * backend's reason code, not on "any error": an unreachable tracker or an expired token would
70
+ * otherwise present as a free-text field that simply moves the same failure to the next click.
71
+ */
72
+ const boardIsFreeText = computed(
73
+ () => !hunt.boardsLoading && hunt.boardsErrorReason === 'boards_unsupported',
74
+ )
75
+
76
+ /** A board read that failed for a reason the user has to fix — shown, never silently swallowed. */
77
+ const boardsFailure = computed(() =>
78
+ !hunt.boardsLoading && hunt.boardsError !== null && !boardIsFreeText.value
79
+ ? hunt.boardsError
80
+ : null,
81
+ )
82
+
83
+ /**
84
+ * A tracker date the SPA can actually format. A provider reports `createdAt` as an opaque
85
+ * string, so an unparseable one must render as nothing rather than as "Invalid Date".
86
+ */
87
+ function createdAtDate(createdAt: string): Date | null {
88
+ if (!createdAt) return null
89
+ const parsed = new Date(createdAt)
90
+ return Number.isNaN(parsed.getTime()) ? null : parsed
91
+ }
92
+
93
+ const canHunt = computed(() => !!source.value && boardId.value.trim().length > 0)
94
+
95
+ watch(open, (isOpen) => {
96
+ if (!isOpen) return
97
+ hunt.reset()
98
+ boardId.value = ''
99
+ issueType.value = ''
100
+ labels.value = ''
101
+ source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
102
+ containerId.value = ui.bugHunt?.containerId ?? containerItems.value[0]?.value
103
+ if (source.value) hunt.loadBoards(source.value)
104
+ })
105
+
106
+ // Switching tracker invalidates both the board list and any ranking already on screen — the
107
+ // candidates belong to the previous tracker's board and would otherwise sit there looking current.
108
+ watch(source, (next) => {
109
+ boardId.value = ''
110
+ hunt.reset()
111
+ if (next) hunt.loadBoards(next)
112
+ })
113
+
114
+ async function runHunt() {
115
+ if (!source.value || !canHunt.value) return
116
+ const parsedLabels = labels.value
117
+ .split(',')
118
+ .map((l) => l.trim())
119
+ .filter((l) => l.length > 0)
120
+ const ok = await hunt.hunt(source.value, {
121
+ board: boardId.value.trim(),
122
+ ...(issueType.value.trim() ? { issueType: issueType.value.trim() } : {}),
123
+ ...(parsedLabels.length ? { labels: parsedLabels } : {}),
124
+ })
125
+ if (!ok) {
126
+ toast.add({
127
+ title: t('bugHunt.huntFailed'),
128
+ description: hunt.huntError ?? undefined,
129
+ icon: 'i-lucide-triangle-alert',
130
+ color: 'error',
131
+ })
132
+ }
133
+ }
134
+
135
+ async function adopt(candidate: BugHuntCandidate) {
136
+ if (!source.value || !containerId.value) return
137
+ try {
138
+ const blockId = await hunt.adopt(source.value, candidate.externalId, containerId.value)
139
+ if (!blockId) return // the user cancelled the personal-credential prompt
140
+ ui.closeBugHunt()
141
+ ui.select(blockId)
142
+ toast.add({
143
+ title: t('bugHunt.adopted', { id: candidate.externalId }),
144
+ description: t('bugHunt.adoptedRunning'),
145
+ icon: 'i-lucide-bug-play',
146
+ })
147
+ } catch (e) {
148
+ toast.add({
149
+ title: t('bugHunt.adoptFailed'),
150
+ description: e instanceof Error ? e.message : String(e),
151
+ icon: 'i-lucide-triangle-alert',
152
+ color: 'error',
153
+ })
154
+ }
155
+ }
156
+
157
+ /** Colour the score chip by how good the impact-per-effort ratio is. */
158
+ function scoreColor(score: number): 'success' | 'primary' | 'neutral' {
159
+ if (score >= 2) return 'success'
160
+ if (score >= 1) return 'primary'
161
+ return 'neutral'
162
+ }
163
+
164
+ /**
165
+ * Confidence labels, as an exhaustive Record over the closed union so a new member fails the
166
+ * typecheck rather than rendering a raw code (the enum→key drift guard).
167
+ */
168
+ const CONFIDENCE_KEYS: Record<BugHuntConfidence, string> = {
169
+ high: 'bugHunt.confidence.high',
170
+ medium: 'bugHunt.confidence.medium',
171
+ low: 'bugHunt.confidence.low',
172
+ }
173
+
174
+ /** The banner shown above the results, per analysis status — same exhaustive-Record guard. */
175
+ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
176
+ ranked: 'bugHunt.status.ranked',
177
+ unavailable: 'bugHunt.status.unavailable',
178
+ failed: 'bugHunt.status.failed',
179
+ over_budget: 'bugHunt.status.over_budget',
180
+ empty: 'bugHunt.status.empty',
181
+ }
182
+ </script>
183
+
184
+ <template>
185
+ <UModal v-model:open="open" :title="t('bugHunt.title')" :ui="{ content: 'max-w-3xl' }">
186
+ <template #title>
187
+ <IntegrationBackTitle :title="t('bugHunt.title')" @back="back" />
188
+ </template>
189
+ <template #body>
190
+ <!-- No tracker offered (none connected/installed, or all disabled). -->
191
+ <div v-if="!tasks.anyOffered" class="space-y-3 text-center">
192
+ <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
193
+ <p class="text-sm text-slate-400">{{ t('bugHunt.connectFirst') }}</p>
194
+ <div class="flex justify-center gap-2">
195
+ <UButton
196
+ v-for="s in tasks.sources"
197
+ :key="s.source"
198
+ color="primary"
199
+ variant="soft"
200
+ :icon="s.icon"
201
+ @click="ui.openTaskConnect(s.source)"
202
+ >
203
+ {{ t('bugHunt.connectSource', { label: s.label }) }}
204
+ </UButton>
205
+ </div>
206
+ </div>
207
+
208
+ <!-- No service frame yet → nowhere for an adopted bug to land. -->
209
+ <p v-else-if="!containerItems.length" class="text-center text-xs text-slate-500">
210
+ {{ t('bugHunt.needFrameFirst') }}
211
+ </p>
212
+
213
+ <div v-else class="space-y-4">
214
+ <p class="text-xs text-slate-400">{{ t('bugHunt.intro') }}</p>
215
+
216
+ <div class="grid gap-3 sm:grid-cols-2">
217
+ <UFormField :label="t('bugHunt.tracker')">
218
+ <USelect v-model="source" :items="sourceItems" class="w-full" />
219
+ </UFormField>
220
+
221
+ <UFormField :label="t('bugHunt.board')">
222
+ <!-- A tracker that can't enumerate its boards gets a free-text field rather than
223
+ an empty picker, so the hunt is still usable. -->
224
+ <UInput
225
+ v-if="boardIsFreeText"
226
+ v-model="boardId"
227
+ :placeholder="t('bugHunt.boardPlaceholder')"
228
+ class="w-full"
229
+ />
230
+ <USelect
231
+ v-else
232
+ v-model="boardId"
233
+ :items="boardItems"
234
+ :loading="hunt.boardsLoading"
235
+ :placeholder="t('bugHunt.pickBoard')"
236
+ class="w-full"
237
+ />
238
+ <!-- A board read that failed for a fixable reason (unreachable site, expired
239
+ token): named, so the user isn't left with an empty picker and no cause. -->
240
+ <p v-if="boardsFailure" class="mt-1 text-xs text-amber-400">
241
+ {{ t('bugHunt.boardsFailed', { reason: boardsFailure }) }}
242
+ </p>
243
+ </UFormField>
244
+
245
+ <UFormField :label="t('bugHunt.issueType')" :help="t('bugHunt.issueTypeHelp')">
246
+ <UInput v-model="issueType" placeholder="bug" class="w-full" />
247
+ </UFormField>
248
+
249
+ <UFormField :label="t('bugHunt.labels')" :help="t('bugHunt.labelsHelp')">
250
+ <UInput v-model="labels" placeholder="regression, checkout" class="w-full" />
251
+ </UFormField>
252
+ </div>
253
+
254
+ <UFormField :label="t('bugHunt.adoptInto')">
255
+ <USelect v-model="containerId" :items="containerItems" class="w-full" />
256
+ </UFormField>
257
+
258
+ <div class="flex items-center gap-2">
259
+ <UButton
260
+ color="primary"
261
+ icon="i-lucide-radar"
262
+ :loading="hunt.hunting"
263
+ :disabled="!canHunt"
264
+ @click="runHunt"
265
+ >
266
+ {{ t('bugHunt.run') }}
267
+ </UButton>
268
+ <span v-if="hunt.hunting" class="text-xs text-slate-400">
269
+ {{ t('bugHunt.running') }}
270
+ </span>
271
+ </div>
272
+
273
+ <!-- Results -->
274
+ <div v-if="hunt.hasResult" class="space-y-3 border-t border-slate-800 pt-3">
275
+ <p class="text-xs text-slate-400">
276
+ {{ t(STATUS_KEYS[hunt.result!.analysisStatus]) }}
277
+ <span v-if="hunt.result!.model" class="text-slate-500">
278
+ {{ t('bugHunt.viaModel', { model: hunt.result!.model }) }}
279
+ </span>
280
+ </p>
281
+ <p v-if="hunt.result!.truncated" class="text-xs text-amber-400">
282
+ {{ t('bugHunt.truncated', { count: hunt.result!.scanned }) }}
283
+ </p>
284
+
285
+ <div
286
+ v-for="candidate in hunt.candidates"
287
+ :key="candidate.externalId"
288
+ class="space-y-2 rounded-md border border-slate-800 p-3"
289
+ >
290
+ <div class="flex items-start justify-between gap-3">
291
+ <div class="min-w-0 space-y-1">
292
+ <div class="flex items-center gap-2">
293
+ <UBadge
294
+ v-if="candidate.analysis"
295
+ :color="scoreColor(candidate.analysis.score)"
296
+ variant="soft"
297
+ >
298
+ {{ n(candidate.analysis.score) }}
299
+ </UBadge>
300
+ <UBadge v-else color="neutral" variant="soft">
301
+ {{ t('bugHunt.notAssessed') }}
302
+ </UBadge>
303
+ <ULink
304
+ :to="candidate.url"
305
+ target="_blank"
306
+ rel="noopener noreferrer"
307
+ class="truncate text-sm font-medium text-slate-100"
308
+ >
309
+ {{ candidate.externalId }}: {{ candidate.title }}
310
+ </ULink>
311
+ </div>
312
+ <p v-if="candidate.analysis" class="text-xs text-slate-400">
313
+ {{
314
+ t('bugHunt.ratings', {
315
+ impact: candidate.analysis.impact,
316
+ complexity: candidate.analysis.complexity,
317
+ confidence: t(CONFIDENCE_KEYS[candidate.analysis.confidence]),
318
+ })
319
+ }}
320
+ </p>
321
+ <p v-if="candidate.analysis?.rationale" class="text-xs text-slate-300">
322
+ {{ candidate.analysis.rationale }}
323
+ </p>
324
+ <div class="flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
325
+ <span v-if="candidate.priority">{{ candidate.priority }}</span>
326
+ <span v-for="label in candidate.labels" :key="label">{{ label }}</span>
327
+ <span v-if="createdAtDate(candidate.createdAt)">
328
+ {{ d(createdAtDate(candidate.createdAt)!, 'short') }}
329
+ </span>
330
+ <span>{{
331
+ t('bugHunt.comments', { count: candidate.commentCount }, candidate.commentCount)
332
+ }}</span>
333
+ </div>
334
+ </div>
335
+ <div class="flex shrink-0 flex-col items-end gap-1">
336
+ <UBadge v-if="candidate.analysis?.recommended" color="success" variant="subtle">
337
+ {{ t('bugHunt.recommended') }}
338
+ </UBadge>
339
+ <UButton
340
+ color="primary"
341
+ variant="soft"
342
+ icon="i-lucide-play"
343
+ size="xs"
344
+ :loading="hunt.adopting === candidate.externalId"
345
+ :disabled="hunt.adopting !== null"
346
+ @click="adopt(candidate)"
347
+ >
348
+ {{ t('bugHunt.adopt') }}
349
+ </UButton>
350
+ </div>
351
+ </div>
352
+ </div>
353
+
354
+ <p v-if="!hunt.candidates.length" class="text-center text-xs text-slate-500">
355
+ {{ t('bugHunt.noCandidates') }}
356
+ </p>
357
+ </div>
358
+ </div>
359
+ </template>
360
+ </UModal>
361
+ </template>
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildSourceChoices, reconcileSource } from './ContextIssuePicker.logic'
3
+ import type { TaskSourceState } from '~/types/domain'
4
+
5
+ /**
6
+ * The pure source-selection behind `<ContextIssuePicker>`. Pins what the always-visible
7
+ * selector promises: the tracker in use is named even when it is the only one, a tracker
8
+ * the workspace hasn't got yet is offered as something to ADD (worded for its actual
9
+ * state), and the selection stays valid as the offered set changes underneath it.
10
+ */
11
+ const state = (source: string, { available = true, enabled = true } = {}): TaskSourceState =>
12
+ ({
13
+ source,
14
+ label: source.toUpperCase(),
15
+ icon: `i-lucide-${source}`,
16
+ available,
17
+ enabled,
18
+ credentialFields: [],
19
+ refLabel: '',
20
+ refPlaceholder: '',
21
+ }) as unknown as TaskSourceState
22
+
23
+ describe('buildSourceChoices', () => {
24
+ it('lists a single offered tracker, marked active (the selector is never hidden)', () => {
25
+ const groups = buildSourceChoices([state('github')], 'github')
26
+ expect(groups).toEqual([
27
+ [
28
+ {
29
+ action: 'select',
30
+ source: 'github',
31
+ label: 'GITHUB',
32
+ icon: 'i-lucide-github',
33
+ active: true,
34
+ },
35
+ ],
36
+ ])
37
+ })
38
+
39
+ it('marks only the selected tracker active', () => {
40
+ const [offered] = buildSourceChoices([state('github'), state('jira')], 'jira')
41
+ expect(offered!.map((c) => [c.source, 'active' in c && c.active])).toEqual([
42
+ ['github', false],
43
+ ['jira', true],
44
+ ])
45
+ })
46
+
47
+ it('offers an unavailable tracker as `connect` and an available-but-off one as `enable`', () => {
48
+ const [, addable] = buildSourceChoices(
49
+ [state('github'), state('jira', { available: false }), state('linear', { enabled: false })],
50
+ 'github',
51
+ )
52
+ expect(addable).toEqual([
53
+ { action: 'connect', source: 'jira', label: 'JIRA', icon: 'i-lucide-jira' },
54
+ { action: 'enable', source: 'linear', label: 'LINEAR', icon: 'i-lucide-linear' },
55
+ ])
56
+ })
57
+
58
+ it('drops empty groups so the menu renders no stray separator', () => {
59
+ expect(buildSourceChoices([state('github')], 'github')).toHaveLength(1)
60
+ expect(buildSourceChoices([state('jira', { available: false })], undefined)).toHaveLength(1)
61
+ expect(buildSourceChoices([], undefined)).toEqual([])
62
+ })
63
+ })
64
+
65
+ describe('reconcileSource', () => {
66
+ it('selects the tracker the user just went off to connect, once it is offered', () => {
67
+ expect(reconcileSource(['github', 'jira'], 'github', 'jira')).toBe('jira')
68
+ })
69
+
70
+ it('keeps the current selection while the connect is still pending', () => {
71
+ expect(reconcileSource(['github'], 'github', 'jira')).toBe('github')
72
+ })
73
+
74
+ it('keeps a still-offered selection', () => {
75
+ expect(reconcileSource(['github', 'jira'], 'jira', null)).toBe('jira')
76
+ })
77
+
78
+ it('falls back to the first offered tracker when the selection stopped being offered', () => {
79
+ expect(reconcileSource(['github'], 'jira', null)).toBe('github')
80
+ })
81
+
82
+ it('selects the first offered tracker when nothing is selected yet', () => {
83
+ expect(reconcileSource(['github'], undefined, null)).toBe('github')
84
+ })
85
+
86
+ it('resolves to nothing when the workspace offers no tracker at all', () => {
87
+ expect(reconcileSource([], 'jira', 'jira')).toBeUndefined()
88
+ })
89
+ })
@@ -0,0 +1,74 @@
1
+ import type { TaskSourceKind, TaskSourceState } from '~/types/domain'
2
+
3
+ /**
4
+ * Pure source-selection logic for `<ContextIssuePicker>`: which tracker the picker is
5
+ * searching, and what the source menu offers. Kept out of the component so both the
6
+ * template's `computed`s and the unit spec call the same code.
7
+ *
8
+ * The menu is deliberately two-tier — pick an already-offered tracker, or go and add
9
+ * one — because "attach a context issue" is where a user first discovers a tracker is
10
+ * missing, and sending them to the Integrations hub loses their in-progress task.
11
+ */
12
+
13
+ /** One row of the picker's source menu. */
14
+ export type SourceChoice =
15
+ /** An offered tracker the picker can search right now. */
16
+ | { action: 'select'; source: TaskSourceKind; label: string; icon: string; active: boolean }
17
+ /**
18
+ * A configured tracker that is not offered yet, so it can be added from here. `connect`
19
+ * has no credential/App behind it; `enable` is connected but toggled off for the
20
+ * workspace. Both open the same connect modal, which serves either case — only the
21
+ * wording differs, so the user isn't told to "connect" something already connected.
22
+ */
23
+ | { action: 'connect' | 'enable'; source: TaskSourceKind; label: string; icon: string }
24
+
25
+ /**
26
+ * The picker's source menu, as non-empty groups (the offered trackers, then the ones the
27
+ * user could add). Empty groups are dropped so the menu never renders a stray separator.
28
+ */
29
+ export function buildSourceChoices(
30
+ sources: TaskSourceState[],
31
+ selected: TaskSourceKind | undefined,
32
+ ): SourceChoice[][] {
33
+ const offered: SourceChoice[] = []
34
+ const addable: SourceChoice[] = []
35
+ for (const s of sources) {
36
+ if (s.available && s.enabled) {
37
+ offered.push({
38
+ action: 'select',
39
+ source: s.source,
40
+ label: s.label,
41
+ icon: s.icon,
42
+ active: s.source === selected,
43
+ })
44
+ } else {
45
+ addable.push({
46
+ action: s.available ? 'enable' : 'connect',
47
+ source: s.source,
48
+ label: s.label,
49
+ icon: s.icon,
50
+ })
51
+ }
52
+ }
53
+ return [offered, addable].filter((group) => group.length > 0)
54
+ }
55
+
56
+ /**
57
+ * The source the picker should hold once the offered set changes — after a connect, a
58
+ * disconnect, or the per-workspace toggle flipping elsewhere.
59
+ *
60
+ * `awaiting` is the tracker the user just left to connect: the moment it becomes offered
61
+ * it wins, so they land back on the source they went to add rather than on whatever was
62
+ * selected before. Otherwise a still-offered selection is kept, and a selection that
63
+ * stopped being offered falls back to the first one (searching a tracker the workspace no
64
+ * longer offers only yields errors).
65
+ */
66
+ export function reconcileSource(
67
+ offered: TaskSourceKind[],
68
+ selected: TaskSourceKind | undefined,
69
+ awaiting: TaskSourceKind | null,
70
+ ): TaskSourceKind | undefined {
71
+ if (awaiting && offered.includes(awaiting)) return awaiting
72
+ if (selected && offered.includes(selected)) return selected
73
+ return offered[0]
74
+ }
@@ -6,8 +6,15 @@
6
6
  // It only *stages* a choice: the caller collects PendingContext items and links
7
7
  // them once the block exists (see useContextLinking). A search hit / pasted ref
8
8
  // carries `needsImport: true` so it's fetched + persisted before linking.
9
+ //
10
+ // The tracker being searched is ALWAYS on screen (even when the workspace offers
11
+ // exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
12
+ // context issue is where a missing integration is discovered, and the connect modal
13
+ // opens over the caller's form rather than navigating away from it.
14
+ import type { DropdownMenuItem } from '@nuxt/ui'
9
15
  import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
10
16
  import EmptyState from '~/components/common/EmptyState.vue'
17
+ import { buildSourceChoices, reconcileSource } from '~/components/tasks/ContextIssuePicker.logic'
11
18
 
12
19
  const props = defineProps<{
13
20
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -23,13 +30,6 @@ const props = defineProps<{
23
30
  * `v-model:source`); omitted, the picker manages it internally (the add-task case).
24
31
  */
25
32
  source?: TaskSourceKind
26
- /**
27
- * Always render the source selector, even with a single offered tracker — so the
28
- * user can see *which* tracker is being searched (the "create task from issue"
29
- * surface, where the source is otherwise invisible). Off by default: the inline
30
- * add-task picker stays compact and only shows a selector when there's a choice.
31
- */
32
- alwaysShowSource?: boolean
33
33
  }>()
34
34
  const emit = defineEmits<{
35
35
  pick: [item: PendingContext]
@@ -38,12 +38,14 @@ const emit = defineEmits<{
38
38
 
39
39
  const { t } = useI18n()
40
40
  const tasks = useTasksStore()
41
+ const ui = useUiStore()
41
42
 
42
43
  const chosen = computed(() => new Set(props.chosenKeys ?? []))
43
44
 
44
45
  // Source: default to the first offered tracker. Controlled when the parent passes
45
- // `source` (write-through to `update:source`), else internal. A selector appears when
46
- // more than one source is offered, or whenever the parent asks (`alwaysShowSource`).
46
+ // `source` (write-through to `update:source`), else internal. The selector is always
47
+ // rendered, single tracker or not which tracker is being searched decides what a
48
+ // pasted key resolves to, so it must never be invisible.
47
49
  const internalSource = ref<TaskSourceKind | undefined>(tasks.offeredSources[0]?.source)
48
50
  const source = computed<TaskSourceKind | undefined>({
49
51
  get: () => props.source ?? internalSource.value,
@@ -52,13 +54,49 @@ const source = computed<TaskSourceKind | undefined>({
52
54
  if (v) emit('update:source', v)
53
55
  },
54
56
  })
55
- const sourceItems = computed(() =>
56
- tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
57
+ const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
58
+
59
+ // The tracker the user left to connect, so it becomes the selection the moment it turns
60
+ // up offered (the connect modal re-probes on success). Also the reconcile trigger for a
61
+ // source that stops being offered — disconnected, or toggled off in settings.
62
+ const awaitingConnect = ref<TaskSourceKind | null>(null)
63
+ function addSource(s: TaskSourceKind) {
64
+ awaitingConnect.value = s
65
+ ui.openTaskConnect(s)
66
+ }
67
+ watch(
68
+ () => tasks.offeredSources.map((s) => s.source),
69
+ (offered) => {
70
+ const next = reconcileSource(offered, source.value, awaitingConnect.value)
71
+ if (next && next === awaitingConnect.value) awaitingConnect.value = null
72
+ if (next !== source.value && next) source.value = next
73
+ },
57
74
  )
58
- const showSourceSelect = computed(
59
- () => sourceItems.value.length > 1 || (props.alwaysShowSource && sourceItems.value.length > 0),
75
+
76
+ // Two-tier menu: pick an offered tracker, or add one that isn't offered yet.
77
+ const sourceMenu = computed<DropdownMenuItem[][]>(() =>
78
+ buildSourceChoices(tasks.sources, source.value).map((group) =>
79
+ group.map((choice) =>
80
+ choice.action === 'select'
81
+ ? {
82
+ label: choice.label,
83
+ icon: choice.icon,
84
+ trailingIcon: choice.active ? 'i-lucide-check' : undefined,
85
+ onSelect: () => {
86
+ source.value = choice.source
87
+ },
88
+ }
89
+ : {
90
+ label:
91
+ choice.action === 'enable'
92
+ ? t('tasks.picker.enableSource', { label: choice.label })
93
+ : t('tasks.picker.connectSource', { label: choice.label }),
94
+ icon: 'i-lucide-plug',
95
+ onSelect: () => addSource(choice.source),
96
+ },
97
+ ),
98
+ ),
60
99
  )
61
- const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
62
100
  const searchable = computed(() => descriptor.value?.searchable ?? false)
63
101
 
64
102
  const query = ref('')
@@ -222,13 +260,25 @@ onMounted(() => {
222
260
 
223
261
  <template>
224
262
  <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
225
- <USelect
226
- v-if="showSourceSelect"
227
- v-model="source"
228
- :items="sourceItems"
229
- size="xs"
230
- class="w-full"
231
- />
263
+ <!-- Which tracker is being searched, always visible, plus the trackers the user
264
+ could add from here (each opens the connect modal over the caller's form). -->
265
+ <div class="flex items-center gap-1.5">
266
+ <span class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
267
+ {{ t('tasks.picker.sourceLabel') }}
268
+ </span>
269
+ <UDropdownMenu :items="sourceMenu" :content="{ side: 'bottom', align: 'start' }">
270
+ <UButton
271
+ color="neutral"
272
+ variant="soft"
273
+ size="xs"
274
+ :icon="icon"
275
+ trailing-icon="i-lucide-chevron-down"
276
+ class="max-w-full"
277
+ >
278
+ <span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
279
+ </UButton>
280
+ </UDropdownMenu>
281
+ </div>
232
282
 
233
283
  <UInput
234
284
  v-model="query"
@@ -27,7 +27,7 @@ const back = useIntegrationBack(open)
27
27
 
28
28
  // The tracker being browsed. Owned here (not the picker) so the epic action and the
29
29
  // ref-input placeholder share the same selected source; passed to the picker via
30
- // `v-model:source` with `always-show-source` so it's always visible + selectable.
30
+ // `v-model:source`, whose selector always shows (and can add) the tracker in use.
31
31
  const source = ref<TaskSourceKind | undefined>(undefined)
32
32
  const ref_ = ref('')
33
33
  const importing = ref(false)
@@ -162,13 +162,11 @@ async function doSpawnEpic() {
162
162
  uses for context issues: search by title, pick an already-imported one,
163
163
  or paste a URL/key — choosing one opens the prefilled add-task form. The
164
164
  search is scoped to the chosen container's repo (so a GitHub search stays
165
- in that service's repo and a pasted URL / bare number resolves there), and
166
- the source selector is always shown so it's clear which tracker is in use. -->
165
+ in that service's repo and a pasted URL / bare number resolves there). -->
167
166
  <UFormField :label="t('tasks.import.searchIssues')">
168
167
  <ContextIssuePicker
169
168
  v-model:source="source"
170
169
  :scope-block-id="containerId"
171
- always-show-source
172
170
  @pick="createFromPick"
173
171
  />
174
172
  </UFormField>