@cat-factory/app 0.237.0 → 0.238.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/board/nodes/BlockNode.vue +33 -152
- package/app/components/context/ContextAttachmentFields.vue +9 -7
- package/app/components/documents/ContextDocumentPicker.vue +130 -12
- package/app/components/documents/TaskContextDocs.vue +18 -16
- package/app/components/panels/AgentStepDetail.vue +11 -0
- package/app/components/panels/StepToolServers.logic.spec.ts +60 -0
- package/app/components/panels/StepToolServers.logic.ts +58 -0
- package/app/components/panels/StepToolServers.vue +105 -0
- package/app/components/tasks/BugHuntModal.vue +70 -61
- package/app/components/tasks/ContextIssuePicker.vue +52 -28
- package/app/components/tasks/TaskImportModal.vue +55 -39
- package/app/composables/useContainerTargets.spec.ts +112 -0
- package/app/composables/useContainerTargets.ts +62 -0
- package/app/stores/ui/navigation.ts +8 -31
- package/app/types/toolServers.ts +2 -0
- package/app/utils/containerTargets.ts +71 -0
- package/app/utils/sourcePicker.spec.ts +258 -0
- package/app/utils/sourcePicker.ts +251 -0
- package/i18n/locales/de.json +23 -6
- package/i18n/locales/en.json +23 -6
- package/i18n/locales/es.json +23 -6
- package/i18n/locales/fr.json +23 -6
- package/i18n/locales/he.json +23 -6
- package/i18n/locales/it.json +23 -6
- package/i18n/locales/ja.json +23 -6
- package/i18n/locales/pl.json +23 -6
- package/i18n/locales/tr.json +23 -6
- package/i18n/locales/uk.json +23 -6
- package/package.json +2 -2
- package/app/utils/taskSources.spec.ts +0 -100
- package/app/utils/taskSources.ts +0 -76
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { toolServerUnavailableReasonSchema } from '@cat-factory/contracts'
|
|
2
|
+
import type { ToolServerUnavailableReason } from '~/types/toolServers'
|
|
3
|
+
|
|
4
|
+
// The step surface's tool-server (MCP) reason mapping. Kept out of the SFC on the same seam as
|
|
5
|
+
// `StepTestReport.logic.ts`, so the vocabulary rule can be asserted without mounting a component.
|
|
6
|
+
//
|
|
7
|
+
// The rule: a dropped tool server is reported with the reason it was dropped for, and a reason
|
|
8
|
+
// this build does not recognise is rendered as unknown rather than dropped or folded onto a
|
|
9
|
+
// neighbour. The vocabulary is CLOSED and PERSISTED on a run, so a step recorded before a member
|
|
10
|
+
// was retired still carries that member, and a chip that silently vanished would report a
|
|
11
|
+
// withheld capability as one that was never declared, which is the confusion this surface exists
|
|
12
|
+
// to end.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The i18n key per reason. An exhaustive `Record` rather than a lookup with a default, so a member
|
|
16
|
+
* added to the wire vocabulary fails to compile here instead of rendering as a blank chip.
|
|
17
|
+
*/
|
|
18
|
+
export const REASON_KEY: Record<ToolServerUnavailableReason, string> = {
|
|
19
|
+
harness_unsupported: 'panels.stepDetail.toolServers.reason.harnessUnsupported',
|
|
20
|
+
transport_unsupported: 'panels.stepDetail.toolServers.reason.transportUnsupported',
|
|
21
|
+
missing_secret: 'panels.stepDetail.toolServers.reason.missingSecret',
|
|
22
|
+
reserved_secret: 'panels.stepDetail.toolServers.reason.reservedSecret',
|
|
23
|
+
oauth_not_connected: 'panels.stepDetail.toolServers.reason.oauthNotConnected',
|
|
24
|
+
oauth_token_failed: 'panels.stepDetail.toolServers.reason.oauthTokenFailed',
|
|
25
|
+
over_budget: 'panels.stepDetail.toolServers.reason.overBudget',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The reason vocabulary as the SCHEMA states it: what a parity assertion grades {@link REASON_KEY} against. */
|
|
29
|
+
export const KNOWN_REASONS = toolServerUnavailableReasonSchema.options
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a persisted reason is a member THIS build knows, derived from the picklist's own options
|
|
33
|
+
* rather than from a list retyped here.
|
|
34
|
+
*
|
|
35
|
+
* A predicate rather than a truthiness check on the lookup, because `REASON_KEY` is an ordinary
|
|
36
|
+
* object literal: a persisted value that happens to name an inherited `Object.prototype` member
|
|
37
|
+
* (`constructor`, `toString`) reads back as a truthy non-key and would be handed to `t` as though
|
|
38
|
+
* it were a translation key, taking the retired-member path away from exactly the case it exists
|
|
39
|
+
* for. Narrowing at the boundary keeps the exhaustive `Record` compile-time guard intact.
|
|
40
|
+
*/
|
|
41
|
+
export function isKnownReason(reason: string): reason is ToolServerUnavailableReason {
|
|
42
|
+
return (KNOWN_REASONS as readonly string[]).includes(reason)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Render one reason, falling back to the retired-member line that names the raw code.
|
|
47
|
+
*
|
|
48
|
+
* `t` is passed in rather than composed here so this stays a pure function: the component owns the
|
|
49
|
+
* i18n instance, and the fallback branch is the one worth testing without one.
|
|
50
|
+
*/
|
|
51
|
+
export function reasonText(
|
|
52
|
+
reason: ToolServerUnavailableReason,
|
|
53
|
+
t: (key: string, params?: Record<string, unknown>) => string,
|
|
54
|
+
): string {
|
|
55
|
+
return isKnownReason(reason)
|
|
56
|
+
? t(REASON_KEY[reason])
|
|
57
|
+
: t('panels.stepDetail.toolServers.reason.unknown', { reason })
|
|
58
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The tool servers (MCP) one dispatch gave its agent, and the ones it declared and dropped.
|
|
3
|
+
// Recorded on the step at dispatch (`step.toolServers`).
|
|
4
|
+
//
|
|
5
|
+
// The unavailable half is the reason this exists. A dropped server was, until now, stated in the
|
|
6
|
+
// agent's own prompt and in one backend warn line, and nowhere a person looks: a run that quietly
|
|
7
|
+
// went without its issue tracker read as a run whose agent simply did not use it. So an
|
|
8
|
+
// unavailable server is a chip of its own with its own translated reason, never a shorter list of
|
|
9
|
+
// the wired ones: "absent" and "zero" must not render the same.
|
|
10
|
+
//
|
|
11
|
+
// SELF-HIDING when the record holds nothing, like the sibling panels around it. The record is
|
|
12
|
+
// written on EVERY container dispatch, so both lists empty is the state of every step on every
|
|
13
|
+
// deployment that registers no tool servers at all — the overwhelming default. That state is a
|
|
14
|
+
// fact about the DECLARATION rather than about this run (the dispatched kind declares none), and
|
|
15
|
+
// the Infrastructure window is where declarations are read; rendering it here would put an empty
|
|
16
|
+
// section on every step of every run to say nothing happened. The distinction absent-vs-empty is
|
|
17
|
+
// still carried on the wire and answered by the debug API, which is where a reader asking it looks.
|
|
18
|
+
import type { StepToolServers, ToolServerUnavailableReason } from '~/types/toolServers'
|
|
19
|
+
import { reasonText } from '~/components/panels/StepToolServers.logic'
|
|
20
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
toolServers: StepToolServers
|
|
24
|
+
/**
|
|
25
|
+
* The kind the STEP is named for. The record carries the kind that was DISPATCHED, and the two
|
|
26
|
+
* differ whenever a helper ran on this step (a gate escalating to `ci-fixer`, the tester handing
|
|
27
|
+
* off to `fixer`, a fork's second phase). Each of those resolves its own kind's declarations and
|
|
28
|
+
* overwrites the record, so the surface names whose capabilities these are instead of letting
|
|
29
|
+
* them read as the step's.
|
|
30
|
+
*/
|
|
31
|
+
stepAgentKind: string
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const { t } = useI18n()
|
|
35
|
+
|
|
36
|
+
const hasAny = computed(
|
|
37
|
+
() => props.toolServers.wired.length > 0 || props.toolServers.unavailable.length > 0,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
/** Set only when a helper re-dispatch owns this record, so the ordinary case renders no extra line. */
|
|
41
|
+
const dispatchedAs = computed(() =>
|
|
42
|
+
props.toolServers.agentKind && props.toolServers.agentKind !== props.stepAgentKind
|
|
43
|
+
? agentKindMeta(props.toolServers.agentKind).label
|
|
44
|
+
: null,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
/** Bind this component's i18n instance onto the pure mapping (see `StepToolServers.logic.ts`). */
|
|
48
|
+
const describeReason = (reason: ToolServerUnavailableReason) =>
|
|
49
|
+
reasonText(reason, (key, params) => t(key, params ?? {}))
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<section
|
|
54
|
+
v-if="hasAny"
|
|
55
|
+
data-testid="step-tool-servers"
|
|
56
|
+
class="scroll-mt-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4"
|
|
57
|
+
>
|
|
58
|
+
<div
|
|
59
|
+
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
60
|
+
>
|
|
61
|
+
<UIcon name="i-lucide-plug" class="h-3.5 w-3.5" />
|
|
62
|
+
<span>{{ t('panels.stepDetail.toolServers.heading') }}</span>
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<p
|
|
66
|
+
v-if="dispatchedAs"
|
|
67
|
+
data-testid="step-tool-servers-dispatched-as"
|
|
68
|
+
class="mb-2 text-[12px] text-slate-400"
|
|
69
|
+
>
|
|
70
|
+
{{ t('panels.stepDetail.toolServers.dispatchedAs', { agent: dispatchedAs }) }}
|
|
71
|
+
</p>
|
|
72
|
+
|
|
73
|
+
<ul v-if="toolServers.wired.length" class="flex flex-wrap gap-1.5">
|
|
74
|
+
<li
|
|
75
|
+
v-for="server in toolServers.wired"
|
|
76
|
+
:key="server.id"
|
|
77
|
+
data-testid="step-tool-server-wired"
|
|
78
|
+
class="inline-flex items-center gap-1.5 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-2 py-0.5 text-[12px] text-emerald-200"
|
|
79
|
+
:title="
|
|
80
|
+
server.tools?.length
|
|
81
|
+
? t('panels.stepDetail.toolServers.narrowed', { tools: server.tools.join(', ') })
|
|
82
|
+
: t('panels.stepDetail.toolServers.allTools')
|
|
83
|
+
"
|
|
84
|
+
>
|
|
85
|
+
<UIcon name="i-lucide-check" class="h-3.5 w-3.5" />
|
|
86
|
+
<span>{{ server.label || server.id }}</span>
|
|
87
|
+
</li>
|
|
88
|
+
</ul>
|
|
89
|
+
|
|
90
|
+
<ul v-if="toolServers.unavailable.length" class="mt-2 space-y-1">
|
|
91
|
+
<li
|
|
92
|
+
v-for="server in toolServers.unavailable"
|
|
93
|
+
:key="server.id"
|
|
94
|
+
data-testid="step-tool-server-unavailable"
|
|
95
|
+
class="flex items-start gap-1.5 text-[12px] text-slate-300"
|
|
96
|
+
>
|
|
97
|
+
<UIcon name="i-lucide-plug-zap" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400/80" />
|
|
98
|
+
<span>
|
|
99
|
+
<span class="font-medium text-slate-200">{{ server.label || server.id }}</span>
|
|
100
|
+
<span class="text-slate-400"> {{ describeReason(server.reason) }}</span>
|
|
101
|
+
</span>
|
|
102
|
+
</li>
|
|
103
|
+
</ul>
|
|
104
|
+
</section>
|
|
105
|
+
</template>
|
|
@@ -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
|
|
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
|
|
16
|
-
// place to find out the tracker holding the bugs isn't connected here yet, and the
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
70
|
-
sourceChoices.value
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
{{
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Create a board task from a connected tracker's issue.
|
|
3
|
-
//
|
|
4
|
-
// title, pick an already-imported one, or paste a URL/key) — choosing one opens the
|
|
2
|
+
// Create a board task from a connected tracker's issue. Use the inline picker to find an issue
|
|
3
|
+
// (search by title, pick an already-imported one, or paste a URL/key): choosing one opens the
|
|
5
4
|
// prefilled add-task form (title seeded, issue staged as linked context) where the
|
|
6
5
|
// user confirms the pipeline / presets before it's created. This is the same picker
|
|
7
6
|
// the add-task form uses for "context issues", so the two behave identically. A
|
|
8
7
|
// pasted parent/epic reference can instead be spawned as a whole linked task group.
|
|
8
|
+
//
|
|
9
|
+
// Where the task lands depends on how the modal was opened, and `useContainerTargets` is the shared
|
|
10
|
+
// answer (`<BugHuntModal>` is the same question from the same frame header). From a service frame's
|
|
11
|
+
// own "create task from issue" button that frame settles the SERVICE, so the modal states it rather
|
|
12
|
+
// than asking; a frame with modules still asks frame-or-which-module, scoped to that frame, because
|
|
13
|
+
// the button never answered that half. Opened standalone (the command bar / the Integrations hub)
|
|
14
|
+
// there is no frame behind it and every container on the board is a candidate.
|
|
9
15
|
import type { TaskSourceKind } from '~/types/domain'
|
|
10
16
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
17
|
+
import { type AddSourceLabels, addChoicesOf, buildSourceChoices } from '~/utils/sourcePicker'
|
|
11
18
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
12
19
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
13
20
|
|
|
14
21
|
const { t } = useI18n()
|
|
15
22
|
const ui = useUiStore()
|
|
16
23
|
const tasks = useTasksStore()
|
|
17
|
-
const board = useBoardStore()
|
|
18
24
|
const toast = useToast()
|
|
19
25
|
|
|
20
26
|
const open = computed({
|
|
@@ -32,37 +38,42 @@ const source = ref<TaskSourceKind | undefined>(undefined)
|
|
|
32
38
|
const ref_ = ref('')
|
|
33
39
|
const importing = ref(false)
|
|
34
40
|
|
|
35
|
-
// When opened from a service frame the modal is the "create a task from an issue"
|
|
36
|
-
// surface; opened standalone it's the general tracker-issue browser/importer.
|
|
37
|
-
const title = computed(() =>
|
|
38
|
-
ui.taskImport?.containerId ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
|
|
39
|
-
)
|
|
40
|
-
|
|
41
41
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
/**
|
|
44
|
+
* The trackers the "nothing connected yet" state offers, worded per add action off the shared
|
|
45
|
+
* builder rather than re-deciding `available ? enable : connect` here. `enable` is connected but
|
|
46
|
+
* toggled off for this workspace, so the user is never told to connect what they already have.
|
|
47
|
+
*/
|
|
48
|
+
const ADD_LABEL: AddSourceLabels<'connect' | 'enable'> = {
|
|
49
|
+
connect: (label) => t('tasks.import.connectSource', { label }),
|
|
50
|
+
enable: (label) => t('tasks.import.enableSource', { label }),
|
|
51
|
+
}
|
|
52
|
+
const addableSources = computed(() => addChoicesOf(buildSourceChoices(tasks.sources, source.value)))
|
|
45
53
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
// Where the new task lands. `pinned` is re-resolved through the board on every read, so a frame
|
|
55
|
+
// deleted while the modal sat open widens back to the whole board AND drops the selection that
|
|
56
|
+
// pointed at it (`useContainerTargets`).
|
|
57
|
+
const {
|
|
58
|
+
pinned: pinnedContainer,
|
|
59
|
+
items: containerItems,
|
|
60
|
+
containerId,
|
|
61
|
+
stated: containerStated,
|
|
62
|
+
reset: resetContainer,
|
|
63
|
+
} = useContainerTargets(() => ui.taskImport?.containerId)
|
|
64
|
+
|
|
65
|
+
// Which surface this is. Derived from the RESOLVED frame rather than the id the modal was opened
|
|
66
|
+
// with, so the title cannot claim the frame-scoped surface while the body renders the standalone
|
|
67
|
+
// browser: they answer "was this opened from a frame" from one source.
|
|
68
|
+
const title = computed(() =>
|
|
69
|
+
pinnedContainer.value ? t('tasks.import.titleCreate') : t('tasks.import.titleBrowse'),
|
|
58
70
|
)
|
|
71
|
+
|
|
59
72
|
watch(open, (isOpen) => {
|
|
60
73
|
if (isOpen) {
|
|
61
74
|
ref_.value = ''
|
|
62
75
|
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
63
|
-
|
|
64
|
-
// fall back to the first container on the board.
|
|
65
|
-
containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
|
|
76
|
+
resetContainer()
|
|
66
77
|
tasks.loadTasks().catch(() => {})
|
|
67
78
|
}
|
|
68
79
|
})
|
|
@@ -125,18 +136,14 @@ async function doSpawnEpic() {
|
|
|
125
136
|
<p class="text-sm text-slate-400">{{ t('tasks.import.connectFirst') }}</p>
|
|
126
137
|
<div class="flex justify-center gap-2">
|
|
127
138
|
<UButton
|
|
128
|
-
v-for="
|
|
129
|
-
:key="
|
|
139
|
+
v-for="choice in addableSources"
|
|
140
|
+
:key="choice.source"
|
|
130
141
|
color="primary"
|
|
131
142
|
variant="soft"
|
|
132
|
-
:icon="
|
|
133
|
-
@click="ui.openTaskConnect(
|
|
143
|
+
:icon="choice.icon"
|
|
144
|
+
@click="ui.openTaskConnect(choice.source)"
|
|
134
145
|
>
|
|
135
|
-
{{
|
|
136
|
-
s.available
|
|
137
|
-
? t('tasks.import.enableSource', { label: s.label })
|
|
138
|
-
: t('tasks.import.connectSource', { label: s.label })
|
|
139
|
-
}}
|
|
146
|
+
{{ ADD_LABEL[choice.action](choice.label) }}
|
|
140
147
|
</UButton>
|
|
141
148
|
</div>
|
|
142
149
|
</div>
|
|
@@ -148,8 +155,17 @@ async function doSpawnEpic() {
|
|
|
148
155
|
|
|
149
156
|
<!-- Main form -->
|
|
150
157
|
<div v-else class="space-y-4">
|
|
151
|
-
<!-- Where the new task lands
|
|
152
|
-
|
|
158
|
+
<!-- Where the new task lands. Stated when there is one legal target (opened from a service
|
|
159
|
+
frame that has no modules); otherwise a real choice, scoped to that frame when the
|
|
160
|
+
modal was opened from one. -->
|
|
161
|
+
<p v-if="containerStated" class="text-xs text-slate-400">
|
|
162
|
+
<i18n-t keypath="tasks.import.creatingIn" tag="span" scope="global">
|
|
163
|
+
<template #container>
|
|
164
|
+
<span class="font-medium text-slate-200">{{ pinnedContainer!.title }}</span>
|
|
165
|
+
</template>
|
|
166
|
+
</i18n-t>
|
|
167
|
+
</p>
|
|
168
|
+
<UFormField v-else :label="t('tasks.import.createTasksIn')">
|
|
153
169
|
<USelect
|
|
154
170
|
v-model="containerId"
|
|
155
171
|
:items="containerItems"
|
|
@@ -160,7 +176,7 @@ async function doSpawnEpic() {
|
|
|
160
176
|
|
|
161
177
|
<!-- Find an issue and create a task from it. Same picker the add-task form
|
|
162
178
|
uses for context issues: search by title, pick an already-imported one,
|
|
163
|
-
or paste a URL/key
|
|
179
|
+
or paste a URL/key, and choosing one opens the prefilled add-task form. The
|
|
164
180
|
search is scoped to the chosen container's repo (so a GitHub search stays
|
|
165
181
|
in that service's repo and a pasted URL / bare number resolves there). -->
|
|
166
182
|
<UFormField v-if="containerId" :label="t('tasks.import.searchIssues')">
|