@cat-factory/app 0.236.1 → 0.237.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -111,12 +111,27 @@ When the backend declares the fields and the SPA only collects them, render them
111
111
  surfaces use it: an initiative preset's create form and a reusable operation's per-case form on a
112
112
  custom task type (`AddTaskModal`). Adding a third is a `:fields` binding, not a component.
113
113
 
114
+ **Grouping is the descriptor's own, through `descriptorFieldSections`**, not a wrapper each surface
115
+ builds: consecutive fields sharing a `section` render under one caption, and the reduction applies
116
+ `showWhen` first, so a section whose every field is hidden renders no caption. Never re-group or
117
+ re-order the fields at a call site, or a form renders in an order its author never wrote.
118
+
119
+ **A captioned run is rendered FLAT, never as a per-run wrapper element** (`descriptorFormRows`
120
+ carries each run's caption on the field that opens it). Run membership is derived state that shifts
121
+ as `showWhen` reveals fields, while a field's identity does not: nesting the fields inside a wrapper
122
+ re-parents them when a boundary moves, and Vue can only do that by unmounting and remounting. The
123
+ remounted input is typically the one being TYPED INTO, because typing into the trigger is what moved
124
+ the boundary, so it loses focus, caret and IME composition mid-keystroke. Keep every field a sibling
125
+ keyed by `field.key` and the diff MOVES it instead. The same trap as keying any list by index, with a
126
+ worse symptom: `descriptorFields.spec.ts` pins that a reveal preserves every field key.
127
+
114
128
  Four rules travel with it. **Validate with the shared `validateDescriptorFields`** so the submit
115
129
  button reflects exactly what the server will refuse, and **submit the shared
116
130
  `sanitizeDescriptorFields` result** so a stale answer on a since-hidden `showWhen` field never
117
- reaches the wire. **The labels are deployment-authored English rendered verbatim**: only the chrome
118
- around them (a path-invalid message, section captions) is i18n, so no descriptor string enters a
119
- locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
131
+ reaches the wire. **Every string a descriptor carries is deployment-authored English rendered
132
+ verbatim**, labels, help, option captions and the `section` grouping captions alike: only the
133
+ platform's own chrome around them (the path-invalid message) is i18n, so no descriptor string enters
134
+ a locale catalog. And **the value-bag rules live in `utils/descriptorFields.ts`, not in the SFC**
120
135
  (`defaultDescriptorValues` for the initial values, `setDescriptorValue` / `setDescriptorCheckbox` /
121
136
  `toggleDescriptorGroupValue` for one edit): what an edit freezes on an entity is what a unit test
122
137
  must be able to reach, and a rule inside a component is only reachable by mounting one.
@@ -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
 
@@ -8,16 +8,18 @@
8
8
  //
9
9
  // It extends `ProviderConnectionTab.vue`'s flat-field pattern with the shapes a declared form needs:
10
10
  // `checkbox-group` (multi-select whose value is `string[]`), `path` (a repo-relative dir with inline
11
- // safety validation), and single-condition `showWhen` visibility. Labels/help/option captions are
12
- // backend-supplied English (the `describeConfig` convention); only the chrome is i18n.
11
+ // safety validation), single-condition `showWhen` visibility, and `section` grouping captions.
12
+ // Labels/help/option/section captions are backend-supplied English (the `describeConfig`
13
+ // convention); only the chrome is i18n.
13
14
  //
14
15
  // The model is the typed `DescriptorFieldValues` map (scalars stay strings, `number` a number,
15
16
  // `checkbox` a boolean, `checkbox-group` a `string[]`), so it round-trips the wire contract and the
16
17
  // shared `validateDescriptorFields` / `sanitizeDescriptorFields` rules unchanged.
17
18
  import { computed } from 'vue'
18
- import { isDescriptorFieldVisible, isSafeRepoDirPath } from '@cat-factory/contracts'
19
+ import { isSafeRepoDirPath } from '@cat-factory/contracts'
19
20
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
20
21
  import {
22
+ descriptorFormRows,
21
23
  descriptorGroupValue,
22
24
  setDescriptorCheckbox,
23
25
  setDescriptorValue,
@@ -39,12 +41,20 @@ const props = withDefaults(
39
41
  const model = defineModel<DescriptorFieldValues>({ required: true })
40
42
  const { t } = useI18n()
41
43
 
42
- // Only fields whose `showWhen` holds against the current values are shown; a hidden field's stale
43
- // value is kept in the model (so re-showing restores it) but the server + client both drop it at
44
- // sanitize/validate time, so it can never freeze an unvalidated value.
45
- const visibleFields = computed(() =>
46
- props.fields.filter((f) => isDescriptorFieldVisible(f, model.value)),
47
- )
44
+ // The fields to render, each carrying the section caption that opens its run. Only fields whose
45
+ // `showWhen` holds against the current values are shown; a hidden field's stale value is kept in the
46
+ // model (so re-showing restores it) but the server + client both drop it at sanitize/validate time,
47
+ // so it can never freeze an unvalidated value.
48
+ //
49
+ // The grouping underneath is the shared `descriptorFieldSections`, so a caption spans exactly what
50
+ // one function says it spans, which is the same statement the boot check refuses a declaration
51
+ // against. A form declaring no section is one uncaptioned run, i.e. byte-for-byte the flat column
52
+ // this component always rendered.
53
+ //
54
+ // The rows are FLAT, and the template keeps them siblings keyed by `field.key`, because run
55
+ // membership shifts as `showWhen` reveals fields while a field's identity does not: see
56
+ // `descriptorFormRows` for why a per-run wrapper would remount the input being typed into.
57
+ const rows = computed(() => descriptorFormRows(props.fields, model.value))
48
58
 
49
59
  // The value-mutation rules live in `utils/descriptorFields.ts` as pure functions over the bag (what
50
60
  // an edit does to it, including the drop-when-empty rule that keeps an unset answer from freezing),
@@ -90,76 +100,93 @@ function selectItems(field: DescriptorField) {
90
100
  </script>
91
101
 
92
102
  <template>
93
- <div v-if="visibleFields.length" class="space-y-4">
94
- <UFormField
95
- v-for="field in visibleFields"
96
- :key="field.key"
97
- :label="field.label"
98
- :help="field.help"
99
- :required="field.required"
100
- :error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
101
- :data-testid="`${testidPrefix}-${field.key}`"
102
- >
103
- <!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
104
- <div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
105
- <UCheckbox
106
- v-for="opt in field.options ?? []"
107
- :key="opt.value"
108
- :model-value="groupValue(field.key).includes(opt.value)"
109
- :label="opt.label"
110
- :data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
111
- @update:model-value="
112
- (v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
113
- "
114
- />
115
- </div>
103
+ <div v-if="rows.length" class="space-y-4">
104
+ <!-- One keyed fragment per FIELD, never per run: a run's caption rides the field that opens it,
105
+ so revealing a field re-captions rows in place instead of re-parenting the inputs (which
106
+ Vue can only do by remounting them, destroying the one being typed into). -->
107
+ <template v-for="{ field, caption, startsGroup } in rows" :key="field.key">
108
+ <!-- The section caption, rendered verbatim above its run (deployment-authored English, like
109
+ the field labels themselves). Its testid is the same on every caption, because a caption
110
+ is arbitrary Unicode a deployment writes in its own language: a spec addresses one by the
111
+ TEXT it is asserting about (`getByTestId(...).filter({ hasText })`) rather than by a
112
+ testid that would have to be slugified to be selectable. -->
113
+ <p
114
+ v-if="caption"
115
+ class="-mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
116
+ :class="{ 'pt-2': startsGroup }"
117
+ :data-testid="`${testidPrefix}-section`"
118
+ >
119
+ {{ caption }}
120
+ </p>
121
+ <UFormField
122
+ :label="field.label"
123
+ :help="field.help"
124
+ :required="field.required"
125
+ :error="pathInvalid(field) ? t('common.pathInvalid') : undefined"
126
+ :class="{ 'pt-2': startsGroup && !caption }"
127
+ :data-testid="`${testidPrefix}-${field.key}`"
128
+ >
129
+ <!-- checkbox-group: a vertical list of toggles whose value is the checked option set. -->
130
+ <div v-if="field.type === 'checkbox-group'" class="space-y-1.5">
131
+ <UCheckbox
132
+ v-for="opt in field.options ?? []"
133
+ :key="opt.value"
134
+ :model-value="groupValue(field.key).includes(opt.value)"
135
+ :label="opt.label"
136
+ :data-testid="`${testidPrefix}-${field.key}-${opt.value}`"
137
+ @update:model-value="
138
+ (v: boolean | 'indeterminate') => toggleGroup(field.key, opt.value, v === true)
139
+ "
140
+ />
141
+ </div>
116
142
 
117
- <USelect
118
- v-else-if="field.type === 'select'"
119
- :model-value="stringValue(field.key)"
120
- :items="selectItems(field)"
121
- class="w-full"
122
- :placeholder="field.placeholder"
123
- @update:model-value="(v: string) => set(field.key, v)"
124
- />
143
+ <USelect
144
+ v-else-if="field.type === 'select'"
145
+ :model-value="stringValue(field.key)"
146
+ :items="selectItems(field)"
147
+ class="w-full"
148
+ :placeholder="field.placeholder"
149
+ @update:model-value="(v: string) => set(field.key, v)"
150
+ />
125
151
 
126
- <USwitch
127
- v-else-if="field.type === 'checkbox'"
128
- :model-value="boolValue(field.key)"
129
- @update:model-value="(v: boolean) => setCheckbox(field, v)"
130
- />
152
+ <USwitch
153
+ v-else-if="field.type === 'checkbox'"
154
+ :model-value="boolValue(field.key)"
155
+ @update:model-value="(v: boolean) => setCheckbox(field, v)"
156
+ />
131
157
 
132
- <UTextarea
133
- v-else-if="field.type === 'textarea'"
134
- :model-value="stringValue(field.key)"
135
- :rows="3"
136
- autoresize
137
- class="w-full"
138
- :maxlength="field.maxLength"
139
- :placeholder="field.placeholder"
140
- @update:model-value="(v: string) => set(field.key, v)"
141
- />
158
+ <UTextarea
159
+ v-else-if="field.type === 'textarea'"
160
+ :model-value="stringValue(field.key)"
161
+ :rows="3"
162
+ autoresize
163
+ class="w-full"
164
+ :maxlength="field.maxLength"
165
+ :placeholder="field.placeholder"
166
+ @update:model-value="(v: string) => set(field.key, v)"
167
+ />
142
168
 
143
- <UInput
144
- v-else-if="field.type === 'number'"
145
- :model-value="numberStr(field.key)"
146
- type="number"
147
- class="w-full font-mono"
148
- :placeholder="field.placeholder"
149
- @update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
150
- />
169
+ <UInput
170
+ v-else-if="field.type === 'number'"
171
+ :model-value="numberStr(field.key)"
172
+ type="number"
173
+ class="w-full font-mono"
174
+ :placeholder="field.placeholder"
175
+ @update:model-value="(v: string) => set(field.key, v === '' ? undefined : Number(v))"
176
+ />
151
177
 
152
- <!-- path + text/password (the untyped default): a single-line input. `path`s stay mono. -->
153
- <UInput
154
- v-else
155
- :model-value="stringValue(field.key)"
156
- :type="field.type === 'password' ? 'password' : 'text'"
157
- class="w-full"
158
- :class="{ 'font-mono': field.type === 'path' }"
159
- :maxlength="field.maxLength"
160
- :placeholder="field.placeholder"
161
- @update:model-value="(v: string) => set(field.key, v)"
162
- />
163
- </UFormField>
178
+ <!-- path + text/password (the untyped default): a single-line input. `path`s stay mono. -->
179
+ <UInput
180
+ v-else
181
+ :model-value="stringValue(field.key)"
182
+ :type="field.type === 'password' ? 'password' : 'text'"
183
+ class="w-full"
184
+ :class="{ 'font-mono': field.type === 'path' }"
185
+ :maxlength="field.maxLength"
186
+ :placeholder="field.placeholder"
187
+ @update:model-value="(v: string) => set(field.key, v)"
188
+ />
189
+ </UFormField>
190
+ </template>
164
191
  </div>
165
192
  </template>
@@ -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) : [],