@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.
@@ -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
- /** This service frame is mounted on more than one board in the org. */
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
- // Single pass over the tasks for both rollups (vs. one filter each).
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
- // repo-scoped issue search are both pinned to this frame (see TaskImportModal).
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
- <!-- ===================== FAR: glanceable chip ===================== -->
283
- <!-- Inert while services are always expanded (showExpanded is always true); the
284
- compact branch below is reached via v-else-if and is likewise inert. -->
285
- <div
286
- v-if="!showExpanded && lod === 'far'"
287
- class="flex w-44 items-center gap-2 rounded-xl border-2 px-3 py-3 shadow-lg backdrop-blur"
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="text-sm font-semibold text-white">{{ block.title }}</div>
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` hidden for a read-only viewer, who keeps the badge +
501
- collapse toggle (view-only affordances). -->
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 is additionally empty for anyone without `integrations.manage`: connecting
52
- // stores a workspace credential and stays admin-tier, while attaching a document moved to the
53
- // member tier, so a member falls through to the disabled Attach button instead of a connect
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.available && canManageIntegrations.value
58
- ? documents.sources.filter((s) => !documents.isConnected(s.source))
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; 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
 
@@ -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
+ }