@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
|
@@ -32,7 +32,11 @@ const { lod } = useSemanticZoom()
|
|
|
32
32
|
const { isTouch } = useViewport()
|
|
33
33
|
|
|
34
34
|
const block = computed<Block | undefined>(() => board.getBlock(props.id))
|
|
35
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* This service frame is mounted on more than one board in the org, which is a fact about the
|
|
37
|
+
* SERVICE that only its own card can state: nothing else on the board says the work lands in a
|
|
38
|
+
* repo another board also drives.
|
|
39
|
+
*/
|
|
36
40
|
const isShared = computed(() => services.isSharedFrame(props.id))
|
|
37
41
|
const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
|
|
38
42
|
|
|
@@ -46,18 +50,7 @@ const taskCount = computed(() => allTasks.value.length)
|
|
|
46
50
|
const hasTasks = computed(
|
|
47
51
|
() => taskCount.value > 0 || modules.value.length > 0 || initiativeBlocks.value.length > 0,
|
|
48
52
|
)
|
|
49
|
-
|
|
50
|
-
const taskStats = computed(() => {
|
|
51
|
-
let merged = 0
|
|
52
|
-
let prReady = 0
|
|
53
|
-
for (const t of allTasks.value) {
|
|
54
|
-
if (t.status === 'done') merged++
|
|
55
|
-
else if (t.status === 'pr_ready') prReady++
|
|
56
|
-
}
|
|
57
|
-
return { merged, prReady }
|
|
58
|
-
})
|
|
59
|
-
const mergedTasks = computed(() => taskStats.value.merged)
|
|
60
|
-
const prTasks = computed(() => taskStats.value.prReady)
|
|
53
|
+
const prTasks = computed(() => allTasks.value.filter((t) => t.status === 'pr_ready').length)
|
|
61
54
|
const canvas = computed(() => board.containerSize(props.id))
|
|
62
55
|
|
|
63
56
|
// Frame status is derived from its tasks — services never reach "done".
|
|
@@ -92,11 +85,6 @@ const FRAME_STATUS_HINT_KEYS: Record<BlockStatus, string> = {
|
|
|
92
85
|
const statusHint = computed(() => t(FRAME_STATUS_HINT_KEYS[frameStatus.value]))
|
|
93
86
|
|
|
94
87
|
const selected = computed(() => ui.selectedBlockId === props.id)
|
|
95
|
-
// Services are always expanded to their task canvas, at every zoom level: there is no
|
|
96
|
-
// chip/compact collapse, so panning is a fixed layout and zooming has no expand/collapse
|
|
97
|
-
// transition to snap on. The far-chip and compact-summary branches in the template are
|
|
98
|
-
// kept (gated off) so the prior behaviour is one edit away if we want chips back.
|
|
99
|
-
const showExpanded = computed(() => true)
|
|
100
88
|
|
|
101
89
|
// Every child whose parked run the frame badge speaks for: its tasks AND its initiative
|
|
102
90
|
// blocks. An initiative is a frame child like a module and runs an ordinary pipeline (its
|
|
@@ -144,10 +132,6 @@ function openFirstApproval() {
|
|
|
144
132
|
if (a) ui.openApprovalDetail(a.instanceId, a.approval.id)
|
|
145
133
|
}
|
|
146
134
|
|
|
147
|
-
function toggleExpand() {
|
|
148
|
-
ui.toggleFrame(props.id)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
135
|
// Expanded frames are not Vue Flow-draggable (so the pane can pan through them),
|
|
152
136
|
// so they're repositioned by grabbing the header instead. The whole header bar is
|
|
153
137
|
// the grab surface — only the action buttons (marked `.nodrag`) opt out, so a press
|
|
@@ -164,14 +148,12 @@ function onFrameHandle(e: PointerEvent) {
|
|
|
164
148
|
const { enter: enterFrame, leave: leaveFrame } = useFrameStacking()
|
|
165
149
|
|
|
166
150
|
function addTask() {
|
|
167
|
-
ui.expandFrame(props.id)
|
|
168
151
|
ui.openAddTask(props.id)
|
|
169
152
|
}
|
|
170
153
|
|
|
171
|
-
// Open the tracker-issue modal scoped to THIS service: the create-in target and the
|
|
172
|
-
//
|
|
154
|
+
// Open the tracker-issue modal scoped to THIS service: the create-in target and the repo-scoped
|
|
155
|
+
// issue search are narrowed to this frame and its modules (see `useContainerTargets`).
|
|
173
156
|
function createTaskFromIssue() {
|
|
174
|
-
ui.expandFrame(props.id)
|
|
175
157
|
ui.openTaskImport(null, props.id)
|
|
176
158
|
}
|
|
177
159
|
|
|
@@ -182,12 +164,10 @@ function addRecurring() {
|
|
|
182
164
|
// Hunt this service's tracker board for a bug worth picking up. Scoped to THIS frame, so
|
|
183
165
|
// an adopted candidate lands here rather than wherever the board's first frame happens to be.
|
|
184
166
|
function huntBugs() {
|
|
185
|
-
ui.expandFrame(props.id)
|
|
186
167
|
ui.openBugHunt(null, props.id)
|
|
187
168
|
}
|
|
188
169
|
|
|
189
170
|
function createInitiative() {
|
|
190
|
-
ui.expandFrame(props.id)
|
|
191
171
|
ui.openCreateInitiative(props.id)
|
|
192
172
|
}
|
|
193
173
|
|
|
@@ -279,119 +259,13 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
279
259
|
/>
|
|
280
260
|
</div>
|
|
281
261
|
|
|
282
|
-
<!-- =====================
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
:class="[selected ? 'border-white' : '', pulseClass]"
|
|
289
|
-
:style="{ borderColor: accent, backgroundColor: accent + '26' }"
|
|
290
|
-
>
|
|
291
|
-
<span class="h-3 w-3 shrink-0 rounded-full" :style="{ backgroundColor: accent }" />
|
|
292
|
-
<span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
|
|
293
|
-
<UIcon
|
|
294
|
-
v-if="bootstrapping"
|
|
295
|
-
name="i-lucide-loader-circle"
|
|
296
|
-
class="ms-auto h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
|
|
297
|
-
:title="t('board.frame.bootstrapping')"
|
|
298
|
-
/>
|
|
299
|
-
<UIcon
|
|
300
|
-
v-else-if="runFailed"
|
|
301
|
-
name="i-lucide-alert-triangle"
|
|
302
|
-
class="ms-auto h-3.5 w-3.5 shrink-0 text-rose-400"
|
|
303
|
-
:title="t('board.frame.runFailed')"
|
|
304
|
-
/>
|
|
305
|
-
<span v-else-if="hasTasks" class="ms-auto shrink-0 text-[11px] text-slate-300">
|
|
306
|
-
{{ mergedTasks }}/{{ taskCount }}
|
|
307
|
-
</span>
|
|
308
|
-
</div>
|
|
309
|
-
|
|
310
|
-
<!-- ===================== COMPACT: summary (collapsed) ===================== -->
|
|
311
|
-
<div
|
|
312
|
-
v-else-if="!showExpanded"
|
|
313
|
-
class="w-56 overflow-hidden rounded-xl border bg-slate-900/90 shadow-xl backdrop-blur"
|
|
314
|
-
:class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
|
|
315
|
-
>
|
|
316
|
-
<div class="h-1.5 w-full" :style="{ backgroundColor: accent }" />
|
|
317
|
-
<!-- bootstrap-in-progress banner -->
|
|
318
|
-
<div
|
|
319
|
-
v-if="bootstrapping"
|
|
320
|
-
class="border-b border-amber-900/50 bg-amber-950/30 px-3 py-2"
|
|
321
|
-
data-testid="bootstrap-progress"
|
|
322
|
-
>
|
|
323
|
-
<div class="flex items-center gap-1.5 text-[11px]">
|
|
324
|
-
<UIcon
|
|
325
|
-
name="i-lucide-loader-circle"
|
|
326
|
-
class="h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
|
|
327
|
-
/>
|
|
328
|
-
<span class="text-amber-300">{{ t('board.frame.bootstrapping') }}</span>
|
|
329
|
-
<span v-if="bootstrapSubtasks" class="ms-auto text-amber-200/80">
|
|
330
|
-
{{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }}
|
|
331
|
-
</span>
|
|
332
|
-
</div>
|
|
333
|
-
<div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
|
|
334
|
-
<div
|
|
335
|
-
class="h-full rounded bg-amber-400 transition-all"
|
|
336
|
-
:style="{ width: bootstrapPct + '%' }"
|
|
337
|
-
/>
|
|
338
|
-
</div>
|
|
339
|
-
<div v-if="run" class="mt-2 flex justify-end">
|
|
340
|
-
<AgentStopButton :run-id="run.runId" :kind="run.kind" size="xs" variant="ghost" />
|
|
341
|
-
</div>
|
|
342
|
-
</div>
|
|
343
|
-
<!-- failed run: shared failure banner + retry -->
|
|
344
|
-
<div v-else-if="runFailed && run" class="p-2">
|
|
345
|
-
<AgentFailureCard :run="run" variant="compact" />
|
|
346
|
-
</div>
|
|
347
|
-
<div class="space-y-2 p-3">
|
|
348
|
-
<div class="flex items-center gap-2">
|
|
349
|
-
<UIcon
|
|
350
|
-
:name="typeMeta!.icon"
|
|
351
|
-
class="h-4 w-4 shrink-0"
|
|
352
|
-
:style="{ color: typeMeta!.accent }"
|
|
353
|
-
/>
|
|
354
|
-
<span class="truncate text-sm font-semibold text-white">{{ block.title }}</span>
|
|
355
|
-
<UBadge
|
|
356
|
-
v-if="isShared"
|
|
357
|
-
color="info"
|
|
358
|
-
variant="subtle"
|
|
359
|
-
size="sm"
|
|
360
|
-
class="shrink-0"
|
|
361
|
-
:title="t('board.frame.sharedTitle')"
|
|
362
|
-
>
|
|
363
|
-
{{ t('board.frame.shared') }}
|
|
364
|
-
</UBadge>
|
|
365
|
-
</div>
|
|
366
|
-
<div class="flex items-center justify-between">
|
|
367
|
-
<UBadge :color="statusMeta.chip as any" variant="subtle" size="sm" :title="statusHint">{{
|
|
368
|
-
statusLabel
|
|
369
|
-
}}</UBadge>
|
|
370
|
-
<span class="text-[11px] text-slate-400">{{
|
|
371
|
-
t('board.frame.taskCount', { count: taskCount }, taskCount)
|
|
372
|
-
}}</span>
|
|
373
|
-
</div>
|
|
374
|
-
<button
|
|
375
|
-
type="button"
|
|
376
|
-
class="nodrag flex w-full items-center gap-1 rounded-md bg-slate-800/60 px-2 py-1 text-[10px] text-slate-300 hover:bg-slate-800"
|
|
377
|
-
@click.stop="toggleExpand"
|
|
378
|
-
>
|
|
379
|
-
<UIcon name="i-lucide-layers" class="h-3 w-3 text-slate-400" />
|
|
380
|
-
<span v-if="hasTasks">{{
|
|
381
|
-
t('board.frame.mergedOfTotal', { merged: mergedTasks, total: taskCount })
|
|
382
|
-
}}</span>
|
|
383
|
-
<span v-else>{{ t('board.frame.noTasksYet') }}</span>
|
|
384
|
-
<span v-if="prTasks" class="text-emerald-400"
|
|
385
|
-
>· {{ t('board.frame.prCount', { count: prTasks }) }}</span
|
|
386
|
-
>
|
|
387
|
-
<UIcon name="i-lucide-chevron-down" class="ms-auto h-3 w-3" />
|
|
388
|
-
</button>
|
|
389
|
-
</div>
|
|
390
|
-
</div>
|
|
391
|
-
|
|
392
|
-
<!-- ===================== EXPANDED: 2D canvas of tasks + modules ===================== -->
|
|
262
|
+
<!-- ===================== The service card: 2D canvas of tasks + modules =====================
|
|
263
|
+
There is no chip or compact variant: a service is always expanded to its task canvas, at
|
|
264
|
+
every zoom level, so panning is a fixed layout and zooming has no expand/collapse
|
|
265
|
+
transition to snap on. The two gated-off branches that used to sit here (a far-zoom chip
|
|
266
|
+
and a collapsed summary) went with the header's collapse control, since between them they
|
|
267
|
+
held the only render of the "Shared" badge, which now lives in the header below. -->
|
|
393
268
|
<div
|
|
394
|
-
v-else
|
|
395
269
|
class="relative overflow-visible rounded-2xl border bg-slate-900/95 shadow-2xl backdrop-blur"
|
|
396
270
|
:class="[selected ? 'border-white' : 'border-slate-700', pulseClass]"
|
|
397
271
|
>
|
|
@@ -484,7 +358,23 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
484
358
|
/>
|
|
485
359
|
</div>
|
|
486
360
|
<div>
|
|
487
|
-
<div class="
|
|
361
|
+
<div class="flex items-center gap-1.5">
|
|
362
|
+
<span class="text-sm font-semibold text-white">{{ block.title }}</span>
|
|
363
|
+
<!-- Mounted on more than one board in the org. On the title row rather than in
|
|
364
|
+
the action strip, because it qualifies the service's NAME: the work lands in
|
|
365
|
+
a repo another board also drives. -->
|
|
366
|
+
<UBadge
|
|
367
|
+
v-if="isShared"
|
|
368
|
+
color="info"
|
|
369
|
+
variant="subtle"
|
|
370
|
+
size="sm"
|
|
371
|
+
class="shrink-0"
|
|
372
|
+
data-testid="frame-shared"
|
|
373
|
+
:title="t('board.frame.sharedTitle')"
|
|
374
|
+
>
|
|
375
|
+
{{ t('board.frame.shared') }}
|
|
376
|
+
</UBadge>
|
|
377
|
+
</div>
|
|
488
378
|
<div class="text-[11px] text-slate-400">{{ typeMeta!.label }}</div>
|
|
489
379
|
</div>
|
|
490
380
|
</div>
|
|
@@ -497,8 +387,8 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
497
387
|
>{{ statusLabel }}</UBadge
|
|
498
388
|
>
|
|
499
389
|
<!-- Board-authoring buttons (create task / from issue / recurring / initiative)
|
|
500
|
-
are `board.write
|
|
501
|
-
|
|
390
|
+
are `board.write`, hidden for a read-only viewer, who keeps the status badge
|
|
391
|
+
(the one view-only affordance here). -->
|
|
502
392
|
<template v-if="access.canWriteBoard.value">
|
|
503
393
|
<UButton
|
|
504
394
|
class="nodrag"
|
|
@@ -562,15 +452,6 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
562
452
|
@click.stop="huntBugs"
|
|
563
453
|
/>
|
|
564
454
|
</template>
|
|
565
|
-
<UButton
|
|
566
|
-
class="nodrag"
|
|
567
|
-
:size="isTouch ? 'sm' : 'xs'"
|
|
568
|
-
variant="ghost"
|
|
569
|
-
color="neutral"
|
|
570
|
-
icon="i-lucide-chevron-up"
|
|
571
|
-
:title="t('board.frame.collapseTitle')"
|
|
572
|
-
@click.stop="toggleExpand"
|
|
573
|
-
/>
|
|
574
455
|
</div>
|
|
575
456
|
</div>
|
|
576
457
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// itself gated on `integrations.manage` for documents, whose ATTACH writes are member-tier while
|
|
16
16
|
// storing the credential is not.
|
|
17
17
|
import type { PendingContext } from '~/composables/useContextLinking'
|
|
18
|
+
import { connectableSources } from '~/utils/sourcePicker'
|
|
18
19
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
19
20
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
20
21
|
|
|
@@ -48,15 +49,16 @@ const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
|
|
|
48
49
|
// connected via the App, so they never appear here); for issues, every configured tracker not yet
|
|
49
50
|
// available. The connect modals are the same ones the Integrations hub opens.
|
|
50
51
|
//
|
|
51
|
-
// The document half
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
// action that opens a modal, takes a token and 403s.
|
|
52
|
+
// The document half goes through the shared `connectableSources`, the ONE answer to "which document
|
|
53
|
+
// sources could I connect", which the picker's own add tier reads too: as three near-copies these
|
|
54
|
+
// disagreed about whether an unavailable integration counted.
|
|
55
55
|
const { canManageIntegrations } = useWorkspaceAccess()
|
|
56
56
|
const connectableDocSources = computed(() =>
|
|
57
|
-
documents.
|
|
58
|
-
|
|
59
|
-
:
|
|
57
|
+
connectableSources(documents.sources, {
|
|
58
|
+
isConnected: documents.isConnected,
|
|
59
|
+
canConnect: canManageIntegrations.value,
|
|
60
|
+
available: documents.available,
|
|
61
|
+
}),
|
|
60
62
|
)
|
|
61
63
|
const connectableIssueSources = computed(() =>
|
|
62
64
|
tasks.available ? tasks.sources.filter((s) => !s.available) : [],
|
|
@@ -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
|
|
56
|
-
//
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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
|
|
64
|
-
documents.
|
|
65
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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(
|
|
130
|
+
@click="ui.openDocumentConnect(connectable[0]!.source)"
|
|
129
131
|
>
|
|
130
|
-
{{ t('documents.taskDocs.connectSourceNamed', { source:
|
|
132
|
+
{{ t('documents.taskDocs.connectSourceNamed', { source: connectable[0]!.label }) }}
|
|
131
133
|
</UButton>
|
|
132
134
|
</template>
|
|
133
135
|
|
|
@@ -7,6 +7,7 @@ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
|
7
7
|
import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
|
|
8
8
|
import StepTestReport from '~/components/panels/StepTestReport.vue'
|
|
9
9
|
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
10
|
+
import StepToolServers from '~/components/panels/StepToolServers.vue'
|
|
10
11
|
import StepReproductionReport from '~/components/panels/StepReproductionReport.vue'
|
|
11
12
|
import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
|
|
12
13
|
import BinaryOutputReport from '~/components/binaryOutput/BinaryOutputReport.vue'
|
|
@@ -613,6 +614,16 @@ async function copyOutput() {
|
|
|
613
614
|
effectiveness, key obstacles). Only when the agent reported one. -->
|
|
614
615
|
<StepEffortReport v-if="step.effortReport" :report="step.effortReport" />
|
|
615
616
|
|
|
617
|
+
<!-- the tool servers (MCP) this dispatch wired, and the ones it dropped with the
|
|
618
|
+
reason. Only on a container step, and self-hiding when the record holds
|
|
619
|
+
nothing: a recorded pair of empty lists is a kind that declared none, which is
|
|
620
|
+
every step on a deployment that registers no tool servers at all. -->
|
|
621
|
+
<StepToolServers
|
|
622
|
+
v-if="step.toolServers"
|
|
623
|
+
:tool-servers="step.toolServers"
|
|
624
|
+
:step-agent-kind="step.agentKind"
|
|
625
|
+
/>
|
|
626
|
+
|
|
616
627
|
<!-- the bugfix REPRODUCTION PROOF: the declared reproducing check run against the
|
|
617
628
|
pre-fix tree and the final one, with both captured outputs, or the agent's
|
|
618
629
|
structural declaration that the bug cannot be reproduced. This panel is the
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { KNOWN_REASONS, REASON_KEY, reasonText } from './StepToolServers.logic'
|
|
3
|
+
import type { ToolServerUnavailableReason } from '~/types/toolServers'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A dropped tool server is the whole point of this surface: until it existed, a run that quietly
|
|
7
|
+
* went without its issue tracker was stated only in the agent's own prompt and one backend warn
|
|
8
|
+
* line. These pin the two ways that could regress: a reason with no copy, and a reason this build
|
|
9
|
+
* does not know rendering as nothing at all.
|
|
10
|
+
*/
|
|
11
|
+
describe('tool-server unavailability reasons', () => {
|
|
12
|
+
it('gives every reason in the wire vocabulary its own copy', () => {
|
|
13
|
+
// Derived from the schema the backend decides against, not from a list retyped here: a member
|
|
14
|
+
// added on the backend then fails THIS assertion instead of shipping as a blank chip.
|
|
15
|
+
expect(Object.keys(REASON_KEY).sort()).toEqual([...KNOWN_REASONS].sort())
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('never points two reasons at one line', () => {
|
|
19
|
+
// Each member names a different fix (a variable to set, a declaration to change, a person to
|
|
20
|
+
// press Connect), so two sharing copy would send an operator to the wrong place.
|
|
21
|
+
const keys = Object.values(REASON_KEY)
|
|
22
|
+
expect(new Set(keys).size).toBe(keys.length)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('renders a retired reason as unknown, naming the raw code', () => {
|
|
26
|
+
// The vocabulary is persisted on a run, so a step recorded under a member since retired reads
|
|
27
|
+
// back with that member. Dropping it would report a withheld tool as one never declared.
|
|
28
|
+
expect(render('legacy_reason')).toEqual([
|
|
29
|
+
{
|
|
30
|
+
key: 'panels.stepDetail.toolServers.reason.unknown',
|
|
31
|
+
params: { reason: 'legacy_reason' },
|
|
32
|
+
},
|
|
33
|
+
])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('takes the same path for a reason that names an Object.prototype member', () => {
|
|
37
|
+
// The mapping is an ordinary object literal, so `REASON_KEY['constructor']` reads back a
|
|
38
|
+
// truthy inherited function. A truthiness check on the lookup would hand THAT to `t` as a
|
|
39
|
+
// translation key, taking the retired-member path away from the one input shape most likely
|
|
40
|
+
// to reach it from a hand-edited or corrupted row.
|
|
41
|
+
for (const inherited of ['constructor', 'toString', 'hasOwnProperty', '__proto__']) {
|
|
42
|
+
expect(render(inherited)).toEqual([
|
|
43
|
+
{
|
|
44
|
+
key: 'panels.stepDetail.toolServers.reason.unknown',
|
|
45
|
+
params: { reason: inherited },
|
|
46
|
+
},
|
|
47
|
+
])
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
/** Every `t` call `reasonText` made, so the assertion is about the key it CHOSE, not the copy. */
|
|
53
|
+
function render(reason: string): { key: string; params?: Record<string, unknown> }[] {
|
|
54
|
+
const seen: { key: string; params?: Record<string, unknown> }[] = []
|
|
55
|
+
reasonText(reason as ToolServerUnavailableReason, (key, params) => {
|
|
56
|
+
seen.push({ key, ...(params ? { params } : {}) })
|
|
57
|
+
return key
|
|
58
|
+
})
|
|
59
|
+
return seen
|
|
60
|
+
}
|