@cat-factory/app 0.236.1 → 0.237.1

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.
@@ -9,6 +9,12 @@
9
9
  // useContextLinking). A search hit / pasted ref carries `needsImport: true` so
10
10
  // it's fetched + persisted before linking. Mirrors ContextIssuePicker.
11
11
  //
12
+ // The source being searched is ALWAYS on screen (even when the workspace has exactly one, and
13
+ // even when it has none), and its menu doubles as the "add a source" affordance: which source is
14
+ // selected decides what a pasted ref resolves to and which repository a file pick browses, and
15
+ // attaching context is where a missing integration is discovered. Connecting opens that source's
16
+ // connect modal OVER the caller's form rather than navigating away from it.
17
+ //
12
18
  // A PASTED REF IS RESOLVED BEFORE IT CAN BE STAGED, and that is the point of the ref
13
19
  // half of this picker. It used to stage whatever text was in the box, so a share link
14
20
  // carrying a title segment and `?p=`/`&t=` tracking params (what Figma's own Copy link
@@ -26,6 +32,7 @@
26
32
  // stageable with the import as the backstop, because only the source's own refusal is evidence
27
33
  // against a link. An outage that made attaching impossible would be a worse failure than the
28
34
  // one the pre-flight fixes.
35
+ import { useId } from 'vue'
29
36
  import type { DocumentRefReason, DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
30
37
  import {
31
38
  classifyRefFailure,
@@ -35,6 +42,13 @@ import {
35
42
  } from '~/components/documents/ContextDocumentPicker.logic'
36
43
  import EmptyState from '~/components/common/EmptyState.vue'
37
44
  import RepoContextDocPicker from '~/components/documents/RepoContextDocPicker.vue'
45
+ import {
46
+ type AddSourceLabels,
47
+ buildConnectionSourceChoices,
48
+ menuIsPickable,
49
+ reconcileSource,
50
+ sourceMenuItems,
51
+ } from '~/utils/sourcePicker'
38
52
 
39
53
  // Repo-backed document sources pick a FILE out of a repository (repo search → file
40
54
  // search / tree browse) instead of the generic free-text catalogue search. Today only
@@ -49,18 +63,80 @@ const emit = defineEmits<{ pick: [item: PendingContext] }>()
49
63
 
50
64
  const { t } = useI18n()
51
65
  const documents = useDocumentsStore()
66
+ const ui = useUiStore()
67
+ // Connecting a source stores a workspace credential and is admin-tier, while attaching what it
68
+ // holds is member-tier, so the menu's "add a source" tier is withheld from a member rather than
69
+ // offering a connect that would 403 (see `connectableSources`). The tier also decides which empty
70
+ // state a reader gets, because "connect one" is not advice you can act on without it.
71
+ const { canManageIntegrations } = useWorkspaceAccess()
52
72
 
53
73
  const chosen = computed(() => new Set(props.chosenKeys ?? []))
54
74
 
55
- // Source: default to the first connected source; a selector appears only when
56
- // more than one is connected (the common case is a single source).
75
+ // Source: default to the first connected source. The selector is always rendered, single source
76
+ // or not: which source is being searched decides what a pasted ref resolves to, so it must never
77
+ // be invisible.
57
78
  const source = ref<DocumentSourceKind | undefined>(documents.connectedSources[0]?.source)
58
- const sourceItems = computed(() =>
59
- documents.connectedSources.map((s) => ({ label: s.label, value: s.source })),
60
- )
61
79
  const descriptor = computed(() =>
62
80
  source.value ? documents.descriptorFor(source.value) : undefined,
63
81
  )
82
+
83
+ // The source the user left to connect, so it becomes the selection the moment it turns up
84
+ // connected (the connect modal re-probes on success). Also the reconcile trigger for a source
85
+ // that stops being connected while the caller's form sat open.
86
+ const awaitingConnect = ref<DocumentSourceKind | null>(null)
87
+ function addSource(s: DocumentSourceKind) {
88
+ awaitingConnect.value = s
89
+ ui.openDocumentConnect(s)
90
+ }
91
+ watch(
92
+ () => documents.connectedSources.map((s) => s.source),
93
+ (connected) => {
94
+ const next = reconcileSource(connected, source.value, awaitingConnect.value)
95
+ if (next && next === awaitingConnect.value) awaitingConnect.value = null
96
+ if (next !== source.value) source.value = next
97
+ },
98
+ )
99
+
100
+ // Two-tier menu: pick a connected source, or connect one the workspace hasn't got yet. A document
101
+ // source carries no per-workspace enable toggle, so `connect` is the ONLY add wording it can need,
102
+ // and `buildConnectionSourceChoices` makes that a fact about the type rather than about this call
103
+ // site: a source that one day gains a toggle fails the typecheck here instead of rendering
104
+ // "Connect X" over something already connected.
105
+ const sourceChoices = computed(() =>
106
+ buildConnectionSourceChoices(documents.sources, {
107
+ isConnected: documents.isConnected,
108
+ canConnect: canManageIntegrations.value,
109
+ available: documents.available,
110
+ selected: source.value,
111
+ }),
112
+ )
113
+ const ADD_LABEL: AddSourceLabels<'connect'> = {
114
+ connect: (label) => t('documents.picker.connectSource', { label }),
115
+ }
116
+ const sourceMenu = computed(() =>
117
+ sourceMenuItems(sourceChoices.value, {
118
+ onSelect: (s) => {
119
+ source.value = s
120
+ },
121
+ onAdd: addSource,
122
+ addLabel: ADD_LABEL,
123
+ }),
124
+ )
125
+
126
+ /**
127
+ * Whether the selector is a CONTROL or just a name. A member's add tier is withheld, so on the
128
+ * common deployment (GitHub docs implicitly connected, nothing else) their menu holds one entry
129
+ * that re-selects what is already selected: a chevron promising a choice that isn't there, which is
130
+ * the same papercut the always-visible selector was meant to remove. The source is still named,
131
+ * which was the point.
132
+ */
133
+ const sourcePickable = computed(() => menuIsPickable(sourceChoices.value))
134
+
135
+ // The trigger's own content is the source NAME, so the visible "Source" caption is joined to it as
136
+ // the accessible name ("Source Confluence"). Per instance: the add-task form and the inspector can
137
+ // both have a picker mounted, and a hard-coded id would make one claim the other's caption.
138
+ const sourceLabelId = useId()
139
+ const sourceTriggerId = useId()
64
140
  const searchable = computed(() => descriptor.value?.searchable ?? false)
65
141
  // A repo-backed source swaps the whole free-text search body for the repo→file picker.
66
142
  const isRepoSource = computed(() => !!source.value && REPO_SOURCES.has(source.value))
@@ -340,17 +416,59 @@ onMounted(() => {
340
416
 
341
417
  <template>
342
418
  <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
343
- <USelect
344
- v-if="sourceItems.length > 1"
345
- v-model="source"
346
- :items="sourceItems"
347
- size="xs"
348
- class="w-full"
419
+ <!-- Which source is being searched, always visible, plus the sources the user could add from
420
+ here (each opens the connect modal over the caller's form). Rendered as plain text when
421
+ there is nothing to decide, which for a member is the usual case. -->
422
+ <div class="flex items-center gap-1.5">
423
+ <span
424
+ :id="sourceLabelId"
425
+ class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
426
+ >
427
+ {{ t('documents.picker.sourceLabel') }}
428
+ </span>
429
+ <UDropdownMenu
430
+ v-if="sourcePickable"
431
+ :items="sourceMenu"
432
+ :content="{ side: 'bottom', align: 'start' }"
433
+ >
434
+ <UButton
435
+ :id="sourceTriggerId"
436
+ color="neutral"
437
+ variant="soft"
438
+ size="xs"
439
+ :icon="icon"
440
+ trailing-icon="i-lucide-chevron-down"
441
+ class="max-w-full"
442
+ :aria-labelledby="`${sourceLabelId} ${sourceTriggerId}`"
443
+ >
444
+ <span class="truncate">{{ descriptor?.label ?? t('documents.picker.noSource') }}</span>
445
+ </UButton>
446
+ </UDropdownMenu>
447
+ <span v-else class="flex min-w-0 items-center gap-1 text-xs text-slate-300">
448
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0" />
449
+ <span class="truncate">{{ descriptor?.label ?? t('documents.picker.noSource') }}</span>
450
+ </span>
451
+ </div>
452
+
453
+ <!-- No source selected: nothing is connected (or the last one was disconnected while this sat
454
+ open), so say so rather than render a search box that can only fail. WHICH sentence depends
455
+ on who is reading: the admin tier is told to connect one, because the menu above is the
456
+ route; a member has no add tier (see `connectableSources`), so telling them to connect a
457
+ source would name an action their own menu withholds. -->
458
+ <EmptyState
459
+ v-if="!source"
460
+ compact
461
+ icon="i-lucide-plug"
462
+ :title="
463
+ canManageIntegrations
464
+ ? t('documents.picker.needsSource')
465
+ : t('documents.picker.needsSourceAdmin')
466
+ "
349
467
  />
350
468
 
351
469
  <!-- Repo-backed source (GitHub / GitLab): pick a repository, then a file. -->
352
470
  <RepoContextDocPicker
353
- v-if="isRepoSource && source"
471
+ v-else-if="isRepoSource"
354
472
  :source="source!"
355
473
  :icon="icon"
356
474
  :chosen-keys="chosenKeys"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { DropdownMenuItem } from '@nuxt/ui'
3
3
  import type { Block } from '~/types/domain'
4
+ import { connectableSources } from '~/utils/sourcePicker'
4
5
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
5
6
  import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
6
7
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
@@ -52,21 +53,22 @@ const chosenKeys = computed(() =>
52
53
  )
53
54
 
54
55
  const connected = computed(() => documents.available && documents.anyConnected)
55
- // Sources the user could connect right now to unlock the picker, when none is
56
- // connected yet (GitHub docs are implicitly connected via the App, so never here).
57
- //
58
- // Empty for anyone without `integrations.manage`, because connecting stores a workspace
59
- // credential and stays admin-tier while ATTACHING moved to the member tier. Offering the
60
- // action to a member was a dead end: the connect modal opened, took a token, and 403'd.
61
- // Attaching is unaffected, which is the point of the split.
56
+ // Sources the user could connect right now to unlock the picker, when none is connected yet (GitHub
57
+ // docs are implicitly connected via the App, so never here). Through the shared
58
+ // `connectableSources`, which owns both terms: an integration the deployment has not configured has
59
+ // nothing to connect to, and connecting stores a workspace credential, which stays admin-tier while
60
+ // ATTACHING moved to the member tier. Offering the action to a member was a dead end (the connect
61
+ // modal opened, took a token, and 403'd); attaching is unaffected, which is the point of the split.
62
62
  const { canManageIntegrations } = useWorkspaceAccess()
63
- const connectableSources = computed(() =>
64
- documents.available && canManageIntegrations.value
65
- ? documents.sources.filter((s) => !documents.isConnected(s.source))
66
- : [],
63
+ const connectable = computed(() =>
64
+ connectableSources(documents.sources, {
65
+ isConnected: documents.isConnected,
66
+ canConnect: canManageIntegrations.value,
67
+ available: documents.available,
68
+ }),
67
69
  )
68
70
  const connectMenu = computed<DropdownMenuItem[][]>(() => [
69
- connectableSources.value.map((s) => ({
71
+ connectable.value.map((s) => ({
70
72
  label: s.label,
71
73
  icon: s.icon,
72
74
  onSelect: () => ui.openDocumentConnect(s.source),
@@ -111,7 +113,7 @@ async function attach(item: PendingContext) {
111
113
  {{ showPicker ? t('common.done') : t('documents.taskDocs.attach') }}
112
114
  </UButton>
113
115
  <UDropdownMenu
114
- v-else-if="connectableSources.length > 1"
116
+ v-else-if="connectable.length > 1"
115
117
  :items="connectMenu"
116
118
  :content="{ side: 'bottom', align: 'end' }"
117
119
  >
@@ -120,14 +122,14 @@ async function attach(item: PendingContext) {
120
122
  </UButton>
121
123
  </UDropdownMenu>
122
124
  <UButton
123
- v-else-if="connectableSources.length === 1"
125
+ v-else-if="connectable.length === 1"
124
126
  color="neutral"
125
127
  variant="soft"
126
128
  size="xs"
127
129
  icon="i-lucide-plug"
128
- @click="ui.openDocumentConnect(connectableSources[0]!.source)"
130
+ @click="ui.openDocumentConnect(connectable[0]!.source)"
129
131
  >
130
- {{ t('documents.taskDocs.connectSourceNamed', { source: connectableSources[0]!.label }) }}
132
+ {{ t('documents.taskDocs.connectSourceNamed', { source: connectable[0]!.label }) }}
131
133
  </UButton>
132
134
  </template>
133
135
 
@@ -8,15 +8,18 @@
8
8
  //
9
9
  // Two states this deliberately renders rather than hides: a board whose ranking was
10
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.
11
+ // useful on its own), and a scan that hit its cap says so, because a silently shortened list
12
+ // reads exactly like an exhaustive one.
13
13
  //
14
14
  // The tracker selector doubles as the "add a tracker" affordance (the same two-tier menu
15
- // `<ContextIssuePicker>` renders, off the shared `buildSourceChoices`): a hunt is a common
16
- // place to find out the tracker holding the bugs isn't connected here yet, and the answer
17
- // has to be a route to that tracker's own connect screen rather than "go find the
15
+ // `<ContextIssuePicker>` renders, off the shared `buildSourceChoices` + `sourceMenuItems`): a hunt
16
+ // is a common place to find out the tracker holding the bugs isn't connected here yet, and the
17
+ // answer has to be a route to that tracker's own connect screen rather than "go find the
18
18
  // Integrations hub". The connect modal opens OVER the hunt, so nothing typed here is lost.
19
- import type { DropdownMenuItem } from '@nuxt/ui'
19
+ //
20
+ // Where an adopted bug lands comes from `useContainerTargets`, shared with `<TaskImportModal>`:
21
+ // both are opened from the same frame-header buttons with the same payload, so the frame either
22
+ // answers "which service" for both or for neither.
20
23
  import type { TaskSourceReadReason } from '@cat-factory/contracts'
21
24
  import type {
22
25
  BugHuntAnalysisStatus,
@@ -24,13 +27,19 @@ import type {
24
27
  BugHuntConfidence,
25
28
  TaskSourceKind,
26
29
  } from '~/types/domain'
27
- import { type SourceChoice, buildSourceChoices, reconcileSource } from '~/utils/taskSources'
30
+ import {
31
+ type AddSourceLabels,
32
+ addChoicesOf,
33
+ buildSourceChoices,
34
+ menuIsPickable,
35
+ reconcileSource,
36
+ sourceMenuItems,
37
+ } from '~/utils/sourcePicker'
28
38
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
29
39
 
30
40
  const { t, d, n } = useI18n()
31
41
  const ui = useUiStore()
32
42
  const tasks = useTasksStore()
33
- const board = useBoardStore()
34
43
  const hunt = useBugHuntStore()
35
44
  const toast = useToast()
36
45
 
@@ -46,61 +55,44 @@ const source = ref<TaskSourceKind | undefined>(undefined)
46
55
  const boardId = ref('')
47
56
  const issueType = ref('')
48
57
  const labels = ref('')
49
- const containerId = ref<string | undefined>(undefined)
50
-
51
- // Containers an adopted bug can land in: every service frame and module on the board. Modules
52
- // are labelled with their parent frame so the choice is unambiguous (same as the import modal).
53
- const containerItems = computed(() =>
54
- board.blocks
55
- .filter((b) => b.level === 'frame' || b.level === 'module')
56
- .map((b) => ({
57
- label:
58
- b.level === 'module'
59
- ? `${board.getBlock(b.parentId ?? '')?.title ?? '?'} › ${b.title}`
60
- : b.title,
61
- value: b.id,
62
- })),
63
- )
58
+ // Where an adopted bug lands. Stated rather than asked when the frame the hunt was opened from is
59
+ // the only legal target; re-resolved through the board, so a frame deleted mid-hunt widens back.
60
+ const {
61
+ pinned: pinnedContainer,
62
+ items: containerItems,
63
+ containerId,
64
+ stated: containerStated,
65
+ reset: resetContainer,
66
+ } = useContainerTargets(() => ui.bugHunt?.containerId)
64
67
 
65
68
  const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
66
69
 
70
+ /**
71
+ * Wording for an addable tracker, as an exhaustive map over the add actions a TRACKER menu can
72
+ * carry: `enable` is connected but toggled off for this workspace, so the user is never told to
73
+ * "connect" something they already connected.
74
+ */
75
+ const ADD_LABEL: AddSourceLabels<'connect' | 'enable'> = {
76
+ connect: (label) => t('bugHunt.connectSource', { label }),
77
+ enable: (label) => t('bugHunt.enableSource', { label }),
78
+ }
79
+
67
80
  // Two-tier tracker menu: pick one the workspace already offers, or add one it doesn't.
68
81
  const sourceChoices = computed(() => buildSourceChoices(tasks.sources, source.value))
69
- const sourceMenu = computed<DropdownMenuItem[][]>(() =>
70
- sourceChoices.value.map((group) =>
71
- group.map((choice) =>
72
- choice.action === 'select'
73
- ? {
74
- label: choice.label,
75
- icon: choice.icon,
76
- trailingIcon: choice.active ? 'i-lucide-check' : undefined,
77
- onSelect: () => {
78
- source.value = choice.source
79
- },
80
- }
81
- : {
82
- label: addLabel(choice),
83
- icon: 'i-lucide-plug',
84
- onSelect: () => addSource(choice.source),
85
- },
86
- ),
87
- ),
82
+ const sourceMenu = computed(() =>
83
+ sourceMenuItems(sourceChoices.value, {
84
+ onSelect: (s) => {
85
+ source.value = s
86
+ },
87
+ onAdd: addSource,
88
+ addLabel: ADD_LABEL,
89
+ }),
88
90
  )
91
+ /** One entry decides nothing, so the tracker is named as a label rather than as a dead control. */
92
+ const sourcePickable = computed(() => menuIsPickable(sourceChoices.value))
89
93
 
90
- /** The trackers that can be added the empty state's buttons, where none is offered yet. */
91
- const addableSources = computed(() =>
92
- sourceChoices.value.flat().filter((c) => c.action !== 'select'),
93
- )
94
-
95
- /**
96
- * Wording for an addable tracker: `enable` is connected but toggled off for this workspace,
97
- * so the user is never told to "connect" something they already connected.
98
- */
99
- function addLabel(choice: SourceChoice): string {
100
- return choice.action === 'enable'
101
- ? t('bugHunt.enableSource', { label: choice.label })
102
- : t('bugHunt.connectSource', { label: choice.label })
103
- }
94
+ /** The trackers that can be added: the empty state's buttons, where none is offered yet. */
95
+ const addableSources = computed(() => addChoicesOf(sourceChoices.value))
104
96
 
105
97
  /**
106
98
  * The tracker the user left to add, so it becomes the selection the moment it turns up
@@ -166,11 +158,11 @@ watch(open, (isOpen) => {
166
158
  labels.value = ''
167
159
  awaitingConnect.value = null
168
160
  source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
169
- containerId.value = ui.bugHunt?.containerId ?? containerItems.value[0]?.value
161
+ resetContainer()
170
162
  if (source.value) hunt.loadBoards(source.value)
171
163
  })
172
164
 
173
- // Switching tracker invalidates both the board list and any ranking already on screen the
165
+ // Switching tracker invalidates both the board list and any ranking already on screen: the
174
166
  // candidates belong to the previous tracker's board and would otherwise sit there looking current.
175
167
  watch(source, (next) => {
176
168
  boardId.value = ''
@@ -267,7 +259,7 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
267
259
  :icon="choice.icon"
268
260
  @click="addSource(choice.source)"
269
261
  >
270
- {{ addLabel(choice) }}
262
+ {{ ADD_LABEL[choice.action](choice.label) }}
271
263
  </UButton>
272
264
  </div>
273
265
  </div>
@@ -284,8 +276,11 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
284
276
  <UFormField :label="t('bugHunt.tracker')">
285
277
  <!-- The selector is also the way to ADD a tracker: each entry in the second group
286
278
  opens that tracker's own connect screen over this modal, so the hunt (and
287
- anything typed into it) is still here when the user comes back. -->
279
+ anything typed into it) is still here when the user comes back. With a single
280
+ entry there is nothing to decide, so the tracker is named as plain text instead:
281
+ a chevron that opens a one-item menu promises a choice that isn't there. -->
288
282
  <UDropdownMenu
283
+ v-if="sourcePickable"
289
284
  :items="sourceMenu"
290
285
  :content="{ side: 'bottom', align: 'start' }"
291
286
  class="w-full"
@@ -300,6 +295,10 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
300
295
  <span class="truncate">{{ descriptor?.label ?? t('bugHunt.pickTracker') }}</span>
301
296
  </UButton>
302
297
  </UDropdownMenu>
298
+ <p v-else class="flex items-center gap-1.5 py-1 text-sm text-slate-300">
299
+ <UIcon v-if="descriptor?.icon" :name="descriptor.icon" class="h-4 w-4 shrink-0" />
300
+ <span class="truncate">{{ descriptor?.label ?? t('bugHunt.pickTracker') }}</span>
301
+ </p>
303
302
  </UFormField>
304
303
 
305
304
  <UFormField :label="t('bugHunt.board')">
@@ -335,7 +334,17 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
335
334
  </UFormField>
336
335
  </div>
337
336
 
338
- <UFormField :label="t('bugHunt.adoptInto')">
337
+ <!-- Where an adopted bug lands. Stated when the frame this hunt was opened from is the
338
+ only legal target; a choice (scoped to that frame) when it has modules, or over the
339
+ whole board when the hunt was opened standalone. -->
340
+ <p v-if="containerStated" class="text-xs text-slate-400">
341
+ <i18n-t keypath="bugHunt.adoptingInto" tag="span" scope="global">
342
+ <template #container>
343
+ <span class="font-medium text-slate-200">{{ pinnedContainer!.title }}</span>
344
+ </template>
345
+ </i18n-t>
346
+ </p>
347
+ <UFormField v-else :label="t('bugHunt.adoptInto')">
339
348
  <USelect v-model="containerId" :items="containerItems" class="w-full" />
340
349
  </UFormField>
341
350
 
@@ -16,12 +16,18 @@
16
16
  // exactly one), and its menu doubles as the "add a tracker" affordance: attaching a
17
17
  // context issue is where a missing integration is discovered, and the connect modal
18
18
  // opens over the caller's form rather than navigating away from it.
19
- import type { DropdownMenuItem } from '@nuxt/ui'
19
+ import { useId } from 'vue'
20
20
  import type { TaskSourceReadReason } from '@cat-factory/contracts'
21
21
  import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
22
22
  import { apiErrorReason } from '~/composables/api/errors'
23
23
  import EmptyState from '~/components/common/EmptyState.vue'
24
- import { buildSourceChoices, reconcileSource } from '~/utils/taskSources'
24
+ import {
25
+ type AddSourceLabels,
26
+ buildSourceChoices,
27
+ menuIsPickable,
28
+ reconcileSource,
29
+ sourceMenuItems,
30
+ } from '~/utils/sourcePicker'
25
31
 
26
32
  const props = defineProps<{
27
33
  /** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
@@ -83,30 +89,32 @@ watch(
83
89
  },
84
90
  )
85
91
 
92
+ /** Wording per add action, exhaustive over what a TRACKER menu can carry. */
93
+ const ADD_LABEL: AddSourceLabels<'connect' | 'enable'> = {
94
+ connect: (label) => t('tasks.picker.connectSource', { label }),
95
+ enable: (label) => t('tasks.picker.enableSource', { label }),
96
+ }
97
+
86
98
  // Two-tier menu: pick an offered tracker, or add one that isn't offered yet.
87
- const sourceMenu = computed<DropdownMenuItem[][]>(() =>
88
- buildSourceChoices(tasks.sources, source.value).map((group) =>
89
- group.map((choice) =>
90
- choice.action === 'select'
91
- ? {
92
- label: choice.label,
93
- icon: choice.icon,
94
- trailingIcon: choice.active ? 'i-lucide-check' : undefined,
95
- onSelect: () => {
96
- source.value = choice.source
97
- },
98
- }
99
- : {
100
- label:
101
- choice.action === 'enable'
102
- ? t('tasks.picker.enableSource', { label: choice.label })
103
- : t('tasks.picker.connectSource', { label: choice.label }),
104
- icon: 'i-lucide-plug',
105
- onSelect: () => addSource(choice.source),
106
- },
107
- ),
108
- ),
99
+ const sourceChoices = computed(() => buildSourceChoices(tasks.sources, source.value))
100
+ const sourceMenu = computed(() =>
101
+ sourceMenuItems(sourceChoices.value, {
102
+ onSelect: (s) => {
103
+ source.value = s
104
+ },
105
+ onAdd: addSource,
106
+ addLabel: ADD_LABEL,
107
+ }),
109
108
  )
109
+ /** One entry decides nothing, so the tracker is named as a label rather than as a dead control. */
110
+ const sourcePickable = computed(() => menuIsPickable(sourceChoices.value))
111
+
112
+ // The trigger's own content is the tracker NAME, which says nothing about what the name means, so
113
+ // the visible "Source" caption is joined to it as the accessible name ("Source GitHub"). Per
114
+ // instance, because this picker and the import modal's copy can be mounted at the same time and a
115
+ // hard-coded id would make one trigger claim the other's caption.
116
+ const sourceLabelId = useId()
117
+ const sourceTriggerId = useId()
110
118
  const searchable = computed(() => descriptor.value?.searchable ?? false)
111
119
 
112
120
  const query = ref('')
@@ -277,24 +285,40 @@ onMounted(() => {
277
285
 
278
286
  <template>
279
287
  <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
280
- <!-- Which tracker is being searched, always visible, plus the trackers the user
281
- could add from here (each opens the connect modal over the caller's form). -->
288
+ <!-- Which tracker is being searched, always visible, plus the trackers the user could add from
289
+ here (each opens the connect modal over the caller's form). With a single entry there is
290
+ nothing to decide, so the tracker is named as plain text: a chevron opening a one-item menu
291
+ promises a choice that isn't there. `id` labels the trigger, whose own content is the
292
+ tracker name rather than what that name means. -->
282
293
  <div class="flex items-center gap-1.5">
283
- <span class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
294
+ <span
295
+ :id="sourceLabelId"
296
+ class="shrink-0 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
297
+ >
284
298
  {{ t('tasks.picker.sourceLabel') }}
285
299
  </span>
286
- <UDropdownMenu :items="sourceMenu" :content="{ side: 'bottom', align: 'start' }">
300
+ <UDropdownMenu
301
+ v-if="sourcePickable"
302
+ :items="sourceMenu"
303
+ :content="{ side: 'bottom', align: 'start' }"
304
+ >
287
305
  <UButton
306
+ :id="sourceTriggerId"
288
307
  color="neutral"
289
308
  variant="soft"
290
309
  size="xs"
291
310
  :icon="icon"
292
311
  trailing-icon="i-lucide-chevron-down"
293
312
  class="max-w-full"
313
+ :aria-labelledby="`${sourceLabelId} ${sourceTriggerId}`"
294
314
  >
295
315
  <span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
296
316
  </UButton>
297
317
  </UDropdownMenu>
318
+ <span v-else class="flex min-w-0 items-center gap-1 text-xs text-slate-300">
319
+ <UIcon :name="icon" class="h-3.5 w-3.5 shrink-0" />
320
+ <span class="truncate">{{ descriptor?.label ?? t('tasks.picker.noSource') }}</span>
321
+ </span>
298
322
  </div>
299
323
 
300
324
  <UInput