@cat-factory/app 0.219.1 → 0.221.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.
@@ -35,6 +35,7 @@ import type { ReviewTargetReason } from '@cat-factory/contracts'
35
35
  import { sanitizeDescriptorFields, validateDescriptorFields } from '@cat-factory/contracts'
36
36
  import { defaultDescriptorValues } from '~/utils/descriptorFields'
37
37
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
38
+ import { buildTaskTypePickerRows } from '~/utils/taskTypePicker'
38
39
 
39
40
  const ui = useUiStore()
40
41
  // Interface tier. In BASIC mode this form asks for the task itself (type, title,
@@ -183,15 +184,15 @@ const customFieldProblems = computed(() => {
183
184
  if (!custom || customFormPanel.value) return []
184
185
  return validateDescriptorFields(custom.fields ?? [], customFieldValues.value)
185
186
  })
186
- // The type picker: the built-in choices (i18n labels) + the custom types (their wire presentation).
187
- const typeChoices = computed<{ value: TaskTypeChoice; label: string; icon: string }[]>(() => [
188
- ...TASK_TYPES.value,
189
- ...customTaskTypes.value.map((tt) => ({
190
- value: tt.taskType as TaskTypeChoice,
191
- label: tt.presentation.label,
192
- icon: tt.presentation.icon,
193
- })),
194
- ])
187
+ // The type picker, laid out as rows (see `buildTaskTypePickerRows`): the built-in choices (i18n
188
+ // labels) first, then the deployment's registered types under their declared `presentation.category`
189
+ // captions, so a catalog of reusable operations reads as sections instead of one wall of buttons.
190
+ // Only the leftovers row's heading is CHROME, so it is the one caption the layer supplies.
191
+ const typeRows = computed(() =>
192
+ buildTaskTypePickerRows(TASK_TYPES.value, customTaskTypes.value, {
193
+ other: t('board.addTask.typeOther'),
194
+ }),
195
+ )
195
196
 
196
197
  // Parse the PR-reference input into the contract fields: a bare positive integer (optionally
197
198
  // `#`-prefixed) becomes `prNumber` (a PR on the service's linked repo); anything else is taken
@@ -768,24 +769,59 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
768
769
  </p>
769
770
 
770
771
  <UFormField :label="t('board.addTask.typeLabel')">
771
- <div class="flex flex-wrap gap-1">
772
- <UButton
773
- v-for="ty in typeChoices"
774
- :key="ty.value"
775
- :color="taskType === ty.value ? 'primary' : 'neutral'"
776
- :variant="taskType === ty.value ? 'soft' : 'ghost'"
777
- :icon="ty.icon"
778
- size="xs"
779
- :data-testid="`task-type-${ty.value}`"
780
- @click="
781
- () => {
782
- taskType = ty.value
783
- }
784
- "
772
+ <!-- One row per picker group: the built-ins uncaptioned, then a caption per registered
773
+ category, then the leftovers. Category captions are deployment-authored English
774
+ rendered verbatim (as are the custom labels and their hover descriptions); only the
775
+ leftovers heading is chrome, so only it is i18n. The row gap must stay WIDER than a
776
+ caption's `mb-1`, or a heading sits equidistant between the group above it and its
777
+ own buttons and the grouping stops reading. -->
778
+ <div class="space-y-3">
779
+ <div
780
+ v-for="row in typeRows"
781
+ :key="row.id"
782
+ data-testid="task-type-row"
783
+ :data-task-type-row="row.id"
785
784
  >
786
- {{ ty.label }}
787
- </UButton>
785
+ <p
786
+ v-if="row.caption"
787
+ class="mb-1 px-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
788
+ data-testid="task-type-category"
789
+ >
790
+ {{ row.caption }}
791
+ </p>
792
+ <div class="flex flex-wrap gap-1">
793
+ <UButton
794
+ v-for="ty in row.choices"
795
+ :key="ty.value"
796
+ :color="taskType === ty.value ? 'primary' : 'neutral'"
797
+ :variant="taskType === ty.value ? 'soft' : 'ghost'"
798
+ :icon="ty.icon"
799
+ size="xs"
800
+ :title="ty.description"
801
+ :data-testid="`task-type-${ty.value}`"
802
+ @click="
803
+ () => {
804
+ taskType = ty.value
805
+ }
806
+ "
807
+ >
808
+ {{ ty.label }}
809
+ </UButton>
810
+ </div>
811
+ </div>
788
812
  </div>
813
+
814
+ <!-- What the selected operation is for, in the deployment's own words, in the field's OWN
815
+ help slot (the `:help` seam every other field here uses) rather than a paragraph
816
+ beside it fighting the modal's spacing. The hover title above helps you choose; this
817
+ states the choice you made, which is the half a touch device can reach. Built-in
818
+ types carry no description (their labels are localized and their meaning is fixed),
819
+ so only a custom type is described here. -->
820
+ <template v-if="selectedCustomType?.presentation.description" #help>
821
+ <span data-testid="task-type-description">
822
+ {{ selectedCustomType.presentation.description }}
823
+ </span>
824
+ </template>
789
825
  </UFormField>
790
826
 
791
827
  <!-- Recurring tasks are configured as a schedule on the service frame. -->
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ // A stored document rendered as a link to its origin page, or as a plain element when it has none.
3
+ //
4
+ // Not every document came from a page: an `upload` is a body handed to the platform through the
5
+ // public API, so it stores an empty `url`. An anchor with an empty `href` navigates to the current
6
+ // page, which reads as a link that BROKE rather than as a document that never had one — the same
7
+ // distinction kernel's `originSuffix` / `originHeaderLine` draw for the agent-facing renderers.
8
+ // One component so the three places the SPA lists documents cannot each get it half right.
9
+ //
10
+ // `hoverClass` is the caller's hover affordance, applied ONLY when there is somewhere to go: a
11
+ // hover style on an element that does not navigate is the same lie as the empty `href`, one
12
+ // rendering later. It lives here rather than at each call site so a caller cannot pass the style
13
+ // and forget the condition.
14
+ const props = defineProps<{ url: string; hoverClass?: string }>()
15
+ const { t } = useI18n()
16
+ </script>
17
+
18
+ <template>
19
+ <component
20
+ :is="props.url ? 'a' : 'span'"
21
+ :class="props.url ? props.hoverClass : undefined"
22
+ v-bind="
23
+ props.url
24
+ ? { href: props.url, title: props.url, target: '_blank', rel: 'noopener' }
25
+ : { title: t('documents.taskDocs.uploadedHint') }
26
+ "
27
+ >
28
+ <slot />
29
+ </component>
30
+ </template>
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { DOC_KINDS } from '~/types/domain'
3
3
  import type { DocKind, DocumentLinkRole, SourceDocument } from '~/types/domain'
4
+ import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
4
5
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
5
6
 
6
7
  // Manage the workspace's per-DocKind TEMPLATE (singular) + EXEMPLAR (multi) document links (WS1).
@@ -156,14 +157,13 @@ async function unlink(doc: SourceDocument) {
156
157
  v-if="template"
157
158
  class="mt-2 flex items-center justify-between gap-2 rounded-md bg-slate-900/70 px-3 py-2"
158
159
  >
159
- <a
160
- :href="template.url"
161
- target="_blank"
162
- rel="noopener"
163
- class="truncate text-sm font-medium text-white hover:underline"
160
+ <DocumentOriginLink
161
+ :url="template.url"
162
+ class="truncate text-sm font-medium text-white"
163
+ hover-class="hover:underline"
164
164
  >
165
165
  {{ template.title }}
166
- </a>
166
+ </DocumentOriginLink>
167
167
  <UButton
168
168
  color="neutral"
169
169
  variant="ghost"
@@ -194,14 +194,13 @@ async function unlink(doc: SourceDocument) {
194
194
  :key="`${doc.source}:${doc.externalId}`"
195
195
  class="flex items-center justify-between gap-2 rounded-md bg-slate-900/70 px-3 py-2"
196
196
  >
197
- <a
198
- :href="doc.url"
199
- target="_blank"
200
- rel="noopener"
201
- class="truncate text-sm font-medium text-white hover:underline"
197
+ <DocumentOriginLink
198
+ :url="doc.url"
199
+ class="truncate text-sm font-medium text-white"
200
+ hover-class="hover:underline"
202
201
  >
203
202
  {{ doc.title }}
204
- </a>
203
+ </DocumentOriginLink>
205
204
  <UButton
206
205
  color="neutral"
207
206
  variant="ghost"
@@ -2,6 +2,7 @@
2
2
  import type { DropdownMenuItem } from '@nuxt/ui'
3
3
  import type { Block } from '~/types/domain'
4
4
  import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
5
+ import DocumentOriginLink from '~/components/documents/DocumentOriginLink.vue'
5
6
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
6
7
 
7
8
  // Documents (from any source) attached to a task OR an initiative as agent
@@ -129,21 +130,19 @@ async function attach(item: PendingContext) {
129
130
  />
130
131
 
131
132
  <div v-if="linked.length" class="space-y-1">
132
- <a
133
+ <DocumentOriginLink
133
134
  v-for="doc in linked"
134
135
  :key="`${doc.source}:${doc.externalId}`"
135
- :href="doc.url"
136
- :title="doc.url"
137
- target="_blank"
138
- rel="noopener"
139
- class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-800/60"
136
+ :url="doc.url"
137
+ class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300"
138
+ hover-class="hover:bg-slate-800/60"
140
139
  >
141
140
  <UIcon
142
- :name="documents.descriptorFor(doc.source)?.icon ?? 'i-lucide-file-text'"
141
+ :name="documents.descriptorForOrigin(doc.source)?.icon ?? 'i-lucide-file-text'"
143
142
  class="h-3.5 w-3.5 shrink-0 text-indigo-400"
144
143
  />
145
144
  <span class="truncate">{{ doc.title }}</span>
146
- </a>
145
+ </DocumentOriginLink>
147
146
  </div>
148
147
  <p v-else class="text-[11px] text-slate-500">
149
148
  {{ emptyHint }}
@@ -13,7 +13,7 @@ import {
13
13
  spawnDocumentContract,
14
14
  unlinkDocumentForKindContract,
15
15
  } from '@cat-factory/contracts'
16
- import type { DocKind, DocumentLinkRole, DocumentSourceKind } from '~/types/domain'
16
+ import type { DocKind, DocumentLinkRole, DocumentOrigin, DocumentSourceKind } from '~/types/domain'
17
17
  import type { ApiContext } from './context'
18
18
 
19
19
  /** Document sources (Confluence, Notion, …): connect, import, search, board-spawn. */
@@ -73,7 +73,7 @@ export function documentsApi({ send, ws }: ApiContext) {
73
73
 
74
74
  linkDocument: (
75
75
  workspaceId: string,
76
- body: { source: DocumentSourceKind; externalId: string; blockId: string },
76
+ body: { source: DocumentOrigin; externalId: string; blockId: string },
77
77
  ) => send(linkDocumentContract, { pathPrefix: ws(workspaceId), body }),
78
78
 
79
79
  // ---- workspace+DocKind template / exemplar links (WS1) ----------------
@@ -83,7 +83,7 @@ export function documentsApi({ send, ws }: ApiContext) {
83
83
  linkDocumentForKind: (
84
84
  workspaceId: string,
85
85
  body: {
86
- source: DocumentSourceKind
86
+ source: DocumentOrigin
87
87
  externalId: string
88
88
  role: DocumentLinkRole
89
89
  docKind: DocKind
@@ -92,7 +92,7 @@ export function documentsApi({ send, ws }: ApiContext) {
92
92
 
93
93
  unlinkDocumentForKind: (
94
94
  workspaceId: string,
95
- body: { source: DocumentSourceKind; externalId: string },
95
+ body: { source: DocumentOrigin; externalId: string },
96
96
  ) => send(unlinkDocumentForKindContract, { pathPrefix: ws(workspaceId), body }),
97
97
  }
98
98
  }
@@ -168,13 +168,30 @@ export function useContextLinking() {
168
168
  * would make it a variable, which defeats both the typed-message-key check and the extractor's
169
169
  * static scan — and the plural choice has to be made against the same count.
170
170
  */
171
+ /**
172
+ * A failure's line in the toast: TRANSLATED copy where the backend named a reason we have a
173
+ * key for, else the server's own prose.
174
+ *
175
+ * The backend does not localize (it emits `details.reason`), so a refusal a user routinely
176
+ * hits — attaching a document another task already holds — would otherwise reach them as
177
+ * English prose in every locale. The full diagnostic dump keeps the raw message regardless,
178
+ * so nothing is lost for support.
179
+ */
180
+ function describeFailure(failure: LinkFailure): string {
181
+ const reason = failure.details?.reason
182
+ if (reason === 'document_already_linked') {
183
+ return t('errors.conflict.description.document_already_linked')
184
+ }
185
+ return failure.message
186
+ }
187
+
171
188
  function presentLinkFailures(
172
189
  failures: LinkFailure[],
173
190
  blockId?: string,
174
191
  opts: { title?: (count: number) => string } = {},
175
192
  ): void {
176
193
  if (failures.length === 0) return
177
- const description = failures.map((f) => `${f.item.title}: ${f.message}`).join('\n')
194
+ const description = failures.map((f) => `${f.item.title}: ${describeFailure(f)}`).join('\n')
178
195
  const report = buildLinkFailureReport(failures, {
179
196
  workspaceId: workspace.workspaceId,
180
197
  blockId,
@@ -256,6 +256,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
256
256
  titleKey: 'errors.conflict.title.ticket_already_linked',
257
257
  descriptionKey: 'errors.conflict.description.ticket_already_linked',
258
258
  },
259
+ document_already_linked: {
260
+ titleKey: 'errors.conflict.title.document_already_linked',
261
+ descriptionKey: 'errors.conflict.description.document_already_linked',
262
+ },
259
263
  }
260
264
 
261
265
  /**
@@ -56,18 +56,18 @@ export default defineNuxtPlugin(() => {
56
56
 
57
57
  ## The landed seams
58
58
 
59
- | Seam | Slot key | Entry shape | Host |
60
- | ----------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- |
61
- | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
62
- | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
63
- | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
64
- | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
65
- | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
66
- | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
67
- | External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
68
- | Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
69
- | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
70
- | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
59
+ | Seam | Slot key | Entry shape | Host |
60
+ | ----------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
61
+ | Run-detail windows | `resultViews` | `{ id: '<ns>:<name>', component }` | `StepResultViewHost` via `dispatchStepView` |
62
+ | Agent kinds (palette data) | `agentKinds` | `{ kind, container, presentation: { label, icon, color, description, category?, resultView? } }` | agents store merge → `agentKindMeta` |
63
+ | Custom task types | `taskTypes` | `{ taskType: '<ns>:<name>', presentation, fields?, defaultPipelineId?, defaultFragmentIds?, formPanel? }` | `AddTaskModal` picker/fields + `TaskCard` badge (via `taskTypeMeta`) |
64
+ | Sidebar / command-palette / toolbar | `nav` | `{ id, labelKey, icon, surfaces, gate?, advanced?, run, sidebar?, command?, toolbar? }` | the three shells via `useNavContributions` |
65
+ | Inspector body panels | `inspectorPanels` | `{ id, component, when(block), order }` (`PanelEntry<Block>`) | `<PanelsOutlet>` in `InspectorPanel` |
66
+ | Top-level overlays | `appOverlays` | `{ id: '<ns>:<name>', component }` | `<AppOverlayHost>` via `useAppOverlays().open(id)` |
67
+ | External tools | `externalTools` | `{ id, title, icon, url, description?, requiredMetadata?, gate?, advanced?, order? }` | the "External tools" sidebar section + palette, via `useNavContributions` |
68
+ | Custom workspace metadata fields | `workspaceMetadataFields` | `{ key, label, description?, placeholder?, type?, options?, order? }` | the Metadata tab of Workspace settings |
69
+ | Multi-step wizards | (journeys) | `registerJourney` + step modules | `<JourneyHost>` / `<JourneyOutlet>` |
70
+ | Locale strings | (i18n) | `i18n/locales/*.json` in the deployment | `@nuxtjs/i18n` layer deep-merge |
71
71
 
72
72
  A `nav` entry may also declare `advanced: true`, which hides it in **basic** interface mode
73
73
  (the shipped default) exactly as it does for the first-party destinations: see
@@ -173,20 +173,48 @@ Values are readable anywhere in the SPA via `useWorkspaceSettingsStore().setting
173
173
 
174
174
  Model a proprietary work item (an "incident", "pentest", "compliance-audit") as a first-class
175
175
  task type, the create-task twin of an agent kind. Contribute `{ taskType: '<ns>:<name>',
176
- presentation: { label, icon, color, description }, fields?, defaultPipelineId?, formPanel? }` to
177
- the `taskTypes` slot (see `acme:incident` in the example module). The SPA merges it into the
178
- create-task picker and the card-badge catalog:
176
+ presentation: { label, icon, color, description, category? }, fields?, defaultPipelineId?,
177
+ defaultFragmentIds?, formPanel? }` to the `taskTypes` slot (see `acme:incident` in the example
178
+ module). The SPA merges it into the create-task picker and the card-badge catalog:
179
179
 
180
180
  - **`presentation`** drives the create-task picker entry and the `TaskCard` type badge (resolved
181
181
  through the pure `taskTypeMeta` read-model: the `agentKindMeta` twin). An UNREGISTERED
182
182
  namespaced type (a stale row after your extension is removed) degrades to the `feature`
183
- presentation, so a leftover string never breaks a card.
184
- - **`fields`** are descriptor-driven create-form inputs (`text` / `textarea` / `number` /
185
- `select`); their values land in the task's sparse `taskTypeFields.custom` bag (no migration).
183
+ presentation, so a leftover string never breaks a card. `description` is rendered verbatim as
184
+ the picker button's tooltip and, once the type is selected, as the type field's help text;
185
+ `category` groups the picker (below).
186
+ - **`fields`** are descriptor-driven create-form inputs over the shared descriptor-form vocabulary
187
+ (`text` / `textarea` / `number` / `select` / `checkbox` / `checkbox-group` / `path`, with
188
+ defaults and `showWhen` visibility; `password` is excluded by construction because a task field
189
+ value reaches prompts and telemetry). Their values land in the task's sparse
190
+ `taskTypeFields.custom` bag (no migration). A BACKEND-registered descriptor is enforced
191
+ server-side on create as well (required answers, option lists, lengths); a code-shipped one is
192
+ known only to the SPA, so the create form is its only check (see the Validation note below).
186
193
  - **`formPanel`** optionally names a bespoke create-form section component you contribute to the
187
194
  `taskTypeFormPanels` slot (paired by that id, like `resultViews`); shown INSTEAD of `fields`. An
188
195
  unpaired id degrades to the descriptor fields.
189
196
  - **`defaultPipelineId`** pre-selects the type's pipeline in the picker.
197
+ - **`defaultFragmentIds`** seed the type's standing context (best-practice fragment ids) onto every
198
+ new task of it, beside whatever it inherits from its service.
199
+
200
+ **The picker is grouped, not flat** (`utils/taskTypePicker.ts`): the built-in types come first in
201
+ one uncaptioned row, then one captioned row per declared `presentation.category` in registration
202
+ order, then any uncategorized types under a translated "Other" heading. Declare a category once you
203
+ ship more than a couple of types, or they pile up behind the everyday `feature` / `bug` choices.
204
+ Categories differing only in case or spacing are ONE row, captioned as you first wrote it, so a
205
+ stray `API delivery` / `API Delivery` pair does not split a category in half.
206
+
207
+ Your own strings (labels, category captions, descriptions) are rendered verbatim and never enter a
208
+ locale catalog; only the platform's own chrome around them is i18n, which is why the "Other" heading
209
+ is the one caption you do not supply. Each row carries `data-testid="task-type-row"` plus
210
+ `data-task-type-row="<id>"`, and each choice `data-testid="task-type-<taskType>"`, so your own e2e
211
+ suite can address a row and the caption inside it.
212
+
213
+ Together, `fields` + `defaultFragmentIds` + `defaultPipelineId` are what turns a task type from a
214
+ badge into a **reusable operation**: a canned unit of work an org runs repeatedly with per-case
215
+ input, whose collected values reach every agent's prompt. See
216
+ [`docs/initiatives/reusable-operations.md`](../../../../docs/initiatives/reusable-operations.md)
217
+ and the `org:introduce-api` worked example in `backend/internal/example-custom-agent`.
190
218
 
191
219
  The **same type can be delivered from the backend** instead of code-shipped: register it on the
192
220
  deployment's app-owned `TaskTypeRegistry` and it arrives in the workspace snapshot's
@@ -6,10 +6,12 @@ import type {
6
6
  DocumentConnection,
7
7
  DocumentLinkRole,
8
8
  DocumentSearchResult,
9
+ DocumentOrigin,
9
10
  DocumentSourceDescriptor,
10
11
  DocumentSourceKind,
11
12
  SourceDocument,
12
13
  } from '~/types/domain'
14
+ import { isConnectableSource } from '@cat-factory/contracts'
13
15
  import { useSourceIntegration } from '~/composables/useSourceIntegration'
14
16
  import { useUpsertList } from '~/composables/useUpsertList'
15
17
  import { useWorkspaceStore } from '~/stores/workspace'
@@ -119,8 +121,21 @@ export const useDocumentsStore = defineStore('documents', () => {
119
121
  return result
120
122
  }
121
123
 
124
+ /**
125
+ * The descriptor for a STORED document's origin, or undefined when it has none.
126
+ *
127
+ * `descriptorFor` is keyed by a connectable `DocumentSourceKind`, and a stored document's
128
+ * origin is wider than that: an `upload` was handed to the platform through the API and has no
129
+ * source behind it to describe. Narrowing through the predicate DERIVED from the source
130
+ * picklist is what keeps that a typed absence rather than an `undefined` the caller trips over,
131
+ * and what makes adding a source fail the build here until it is handled.
132
+ */
133
+ function descriptorForOrigin(origin: DocumentOrigin): DocumentSourceDescriptor | undefined {
134
+ return isConnectableSource(origin) ? descriptorFor(origin) : undefined
135
+ }
136
+
122
137
  /** Attach an imported page to a block as agent context. */
123
- async function linkToBlock(blockId: string, source: DocumentSourceKind, externalId: string) {
138
+ async function linkToBlock(blockId: string, source: DocumentOrigin, externalId: string) {
124
139
  const doc = await api.linkDocument(workspace.requireId(), { source, externalId, blockId })
125
140
  upsertDoc(doc)
126
141
  return doc
@@ -148,7 +163,7 @@ export const useDocumentsStore = defineStore('documents', () => {
148
163
  * kind, then reconcile the local list (a template replaces the prior one for its kind).
149
164
  */
150
165
  async function linkForKind(
151
- source: DocumentSourceKind,
166
+ source: DocumentOrigin,
152
167
  externalId: string,
153
168
  role: DocumentLinkRole,
154
169
  docKind: DocKind,
@@ -171,7 +186,7 @@ export const useDocumentsStore = defineStore('documents', () => {
171
186
  }
172
187
 
173
188
  /** Clear a document's role tag (built-in template resumes for the kind / exemplar drops). */
174
- async function unlinkForKind(source: DocumentSourceKind, externalId: string) {
189
+ async function unlinkForKind(source: DocumentOrigin, externalId: string) {
175
190
  await api.unlinkDocumentForKind(workspace.requireId(), { source, externalId })
176
191
  roleLinks.value = roleLinks.value.filter(
177
192
  (d) => !(d.source === source && d.externalId === externalId),
@@ -187,6 +202,7 @@ export const useDocumentsStore = defineStore('documents', () => {
187
202
  connectedSources,
188
203
  anyConnected,
189
204
  descriptorFor,
205
+ descriptorForOrigin,
190
206
  connectionFor,
191
207
  isConnected,
192
208
  docsForBlock,
@@ -10,6 +10,7 @@
10
10
 
11
11
  export type {
12
12
  DocumentSourceKind,
13
+ DocumentOrigin,
13
14
  DocumentLinkRole,
14
15
  CredentialField,
15
16
  DocumentSourceDescriptor,
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import type { CustomTaskType } from '~/types/domain'
3
+ import { buildTaskTypePickerRows, type TaskTypePickerChoice } from './taskTypePicker'
4
+
5
+ const BUILT_INS: TaskTypePickerChoice[] = [
6
+ { value: 'feature', label: 'Feature', icon: 'i-lucide-sparkles' },
7
+ { value: 'bug', label: 'Bug', icon: 'i-lucide-bug' },
8
+ ]
9
+
10
+ /** The localized chrome the caller passes in; the util never authors a display string itself. */
11
+ const CAPTIONS = { other: 'Other' }
12
+
13
+ const custom = (
14
+ taskType: string,
15
+ category?: string,
16
+ description = `What ${taskType} does`,
17
+ ): CustomTaskType =>
18
+ ({
19
+ taskType,
20
+ presentation: {
21
+ label: taskType.split(':')[1] ?? taskType,
22
+ icon: 'i-lucide-plug',
23
+ color: '#0ea5e9',
24
+ description,
25
+ ...(category === undefined ? {} : { category }),
26
+ },
27
+ }) as CustomTaskType
28
+
29
+ describe('buildTaskTypePickerRows', () => {
30
+ it('puts the built-in types first, in one uncaptioned row', () => {
31
+ const rows = buildTaskTypePickerRows(
32
+ BUILT_INS,
33
+ [custom('org:introduce-api', 'API delivery')],
34
+ CAPTIONS,
35
+ )
36
+
37
+ expect(rows[0]).toEqual({ id: 'built-in', caption: null, choices: BUILT_INS })
38
+ expect(rows[1]?.caption).toBe('API delivery')
39
+ })
40
+
41
+ it('groups custom types sharing a category under one caption, in first-appearance order', () => {
42
+ const rows = buildTaskTypePickerRows(
43
+ BUILT_INS,
44
+ [
45
+ custom('org:introduce-api', 'API delivery'),
46
+ custom('org:onboard-tenant', 'Tenancy'),
47
+ custom('org:retire-endpoint', 'API delivery'),
48
+ ],
49
+ CAPTIONS,
50
+ )
51
+
52
+ // Registration order is the only order the deployment expressed, so it is the caption order,
53
+ // NOT alphabetical (which would put 'API delivery' first by accident here).
54
+ expect(rows.slice(1).map((r) => r.caption)).toEqual(['API delivery', 'Tenancy'])
55
+ expect(rows[1]?.choices.map((c) => c.value)).toEqual([
56
+ 'org:introduce-api',
57
+ 'org:retire-endpoint',
58
+ ])
59
+ expect(rows[2]?.choices.map((c) => c.value)).toEqual(['org:onboard-tenant'])
60
+ })
61
+
62
+ it('carries the verbatim wire presentation onto each custom choice', () => {
63
+ const rows = buildTaskTypePickerRows(
64
+ BUILT_INS,
65
+ [custom('org:introduce-api', 'API delivery', 'Expose functionality over the standard API.')],
66
+ CAPTIONS,
67
+ )
68
+
69
+ expect(rows[1]?.choices[0]).toEqual({
70
+ value: 'org:introduce-api',
71
+ label: 'introduce-api',
72
+ icon: 'i-lucide-plug',
73
+ description: 'Expose functionality over the standard API.',
74
+ })
75
+ })
76
+
77
+ it('folds captions differing only in case or spacing into one row, keeping the first spelling', () => {
78
+ const rows = buildTaskTypePickerRows(
79
+ BUILT_INS,
80
+ [custom('org:introduce-api', 'API delivery'), custom('org:retire-endpoint', 'api DELIVERY')],
81
+ CAPTIONS,
82
+ )
83
+
84
+ // One row, captioned as the deployment first wrote it: a second heading differing only in case
85
+ // would read as a second category its author never declared.
86
+ expect(rows).toHaveLength(2)
87
+ expect(rows[1]?.caption).toBe('API delivery')
88
+ expect(rows[1]?.id).toBe('category:api delivery')
89
+ expect(rows[1]?.choices.map((c) => c.value)).toEqual([
90
+ 'org:introduce-api',
91
+ 'org:retire-endpoint',
92
+ ])
93
+ })
94
+
95
+ it('keeps captions that differ beyond case and spacing apart, non-ASCII included', () => {
96
+ const rows = buildTaskTypePickerRows(
97
+ [],
98
+ [custom('org:ambito', 'Ámbito'), custom('org:embito', 'Émbito')],
99
+ CAPTIONS,
100
+ )
101
+
102
+ expect(rows.map((r) => r.caption)).toEqual(['Ámbito', 'Émbito'])
103
+ })
104
+
105
+ it('captions the trailing uncategorized row with the caller chrome string', () => {
106
+ const rows = buildTaskTypePickerRows(
107
+ BUILT_INS,
108
+ [
109
+ custom('acme:incident'),
110
+ custom('org:introduce-api', 'API delivery'),
111
+ custom('acme:pentest'),
112
+ ],
113
+ CAPTIONS,
114
+ )
115
+
116
+ expect(rows.map((r) => r.caption)).toEqual([null, 'API delivery', 'Other'])
117
+ expect(rows[2]).toEqual({
118
+ id: 'other',
119
+ caption: 'Other',
120
+ choices: [
121
+ expect.objectContaining({ value: 'acme:incident' }),
122
+ expect.objectContaining({ value: 'acme:pentest' }),
123
+ ],
124
+ })
125
+ })
126
+
127
+ it('leaves the uncategorized row uncaptioned when it is the only row', () => {
128
+ // Nothing precedes it, so a heading would name a distinction the picker does not show.
129
+ const rows = buildTaskTypePickerRows([], [custom('acme:incident')], CAPTIONS)
130
+
131
+ expect(rows).toEqual([
132
+ {
133
+ id: 'other',
134
+ caption: null,
135
+ choices: [expect.objectContaining({ value: 'acme:incident' })],
136
+ },
137
+ ])
138
+ })
139
+
140
+ it('treats a blank category as no category rather than an empty caption', () => {
141
+ // A CODE-shipped consumer type skips the wire schema's `v.trim()`, so this is reachable.
142
+ const rows = buildTaskTypePickerRows(BUILT_INS, [custom('acme:incident', ' ')], CAPTIONS)
143
+
144
+ expect(rows.map((r) => r.id)).toEqual(['built-in', 'other'])
145
+ })
146
+
147
+ it('emits no rows for empty inputs, and no custom rows when nothing is registered', () => {
148
+ expect(buildTaskTypePickerRows([], [], CAPTIONS)).toEqual([])
149
+ expect(buildTaskTypePickerRows(BUILT_INS, [], CAPTIONS).map((r) => r.id)).toEqual(['built-in'])
150
+ })
151
+
152
+ it('keeps a row per category even when only custom types are offered', () => {
153
+ const rows = buildTaskTypePickerRows(
154
+ [],
155
+ [custom('org:introduce-api', 'API delivery')],
156
+ CAPTIONS,
157
+ )
158
+
159
+ expect(rows.map((r) => r.caption)).toEqual(['API delivery'])
160
+ })
161
+ })
@@ -0,0 +1,127 @@
1
+ import type { CustomTaskType } from '~/types/domain'
2
+
3
+ /**
4
+ * The create-task type picker's layout rule (reusable-operations initiative, slice 3).
5
+ *
6
+ * The picker was one flat button row, which is right for the handful of built-in types and wrong
7
+ * the moment a deployment registers a catalog of REUSABLE OPERATIONS: an org with twenty of them
8
+ * ("Introduce API", "Retire endpoint", "Add tenant", …) turns the row into an undifferentiated
9
+ * wall in which the everyday `feature` / `bug` choices are no longer findable. Each custom type
10
+ * may declare a `presentation.category`, and this is where that axis becomes rows.
11
+ *
12
+ * Extracted rather than inlined in `AddTaskModal.vue` for the same reason as
13
+ * `buildFragmentCategoryGroups`: the ORDER is the behaviour worth pinning, and a rule inside an
14
+ * SFC is only reachable by mounting one.
15
+ */
16
+
17
+ /** One selectable type in the picker. */
18
+ export interface TaskTypePickerChoice<T extends string = string> {
19
+ /** The task type id submitted on create. */
20
+ value: T
21
+ /** Button label: an i18n string for a built-in, the verbatim wire presentation for a custom type. */
22
+ label: string
23
+ /** Icon id (`i-lucide-*`). */
24
+ icon: string
25
+ /**
26
+ * The deployment-authored one-liner from a CUSTOM type's presentation, rendered verbatim (never
27
+ * i18n). Absent for a built-in type, whose meaning is fixed and whose label is already localized.
28
+ */
29
+ description?: string
30
+ }
31
+
32
+ /** One row of the picker: the choices under an optional caption. */
33
+ export interface TaskTypePickerRow<T extends string = string> {
34
+ /**
35
+ * Stable `v-for` key, also published as the row's `data-task-type-row` attribute so a caption
36
+ * (whose `data-testid` repeats per row, exactly as `pipeline-step`'s does) is addressable through
37
+ * its row instead of by position. Never rendered as text: `built-in`, `other`, or
38
+ * `category:<folded caption>`.
39
+ */
40
+ id: string
41
+ /**
42
+ * The row's heading, or `null` for an UNCAPTIONED row. Deployment-authored and rendered verbatim
43
+ * for a category row; the caller's localized chrome string for the leftovers row.
44
+ */
45
+ caption: string | null
46
+ choices: TaskTypePickerChoice<T>[]
47
+ }
48
+
49
+ /** The localized CHROME captions the picker needs; every other caption is deployment-authored. */
50
+ export interface TaskTypePickerCaptions {
51
+ /** Heading for the trailing row of custom types that declared no category. */
52
+ other: string
53
+ }
54
+
55
+ /**
56
+ * Lay the picker out as rows: the BUILT-IN types first in one uncaptioned row (the everyday
57
+ * delivery loop stays where it has always been), then one captioned row per declared category in
58
+ * first-appearance order (the deployment's own registration order, which is the only order it
59
+ * expressed), then any uncategorized custom types in a trailing row captioned `captions.other`.
60
+ *
61
+ * Deliberately no collapsing, no overflow menu and no alphabetical re-sort: an operation catalog
62
+ * that needs those has outgrown what a create-task dialog should be asking, and each would hide a
63
+ * choice behind a second interaction.
64
+ */
65
+ export function buildTaskTypePickerRows<T extends string>(
66
+ builtIns: readonly TaskTypePickerChoice<T>[],
67
+ customTypes: readonly CustomTaskType[],
68
+ captions: TaskTypePickerCaptions,
69
+ ): TaskTypePickerRow<T>[] {
70
+ const rows: TaskTypePickerRow<T>[] = []
71
+ if (builtIns.length > 0) rows.push({ id: 'built-in', caption: null, choices: [...builtIns] })
72
+
73
+ const byCategory = new Map<string, { caption: string; choices: TaskTypePickerChoice<T>[] }>()
74
+ const uncategorized: TaskTypePickerChoice<T>[] = []
75
+ for (const type of customTypes) {
76
+ const choice = toChoice<T>(type)
77
+ // A category is `v.trim()`-ed on the wire, but a CODE-shipped consumer type is trusted and
78
+ // unvalidated (see `docs/consumer-extensions.md`), so a whitespace-only caption is possible
79
+ // and must read as "no category" rather than render an empty heading.
80
+ const caption = type.presentation.category?.trim()
81
+ if (!caption) {
82
+ uncategorized.push(choice)
83
+ continue
84
+ }
85
+ const key = categoryKey(caption)
86
+ const bucket = byCategory.get(key)
87
+ if (bucket) bucket.choices.push(choice)
88
+ else byCategory.set(key, { caption, choices: [choice] })
89
+ }
90
+
91
+ for (const [key, { caption, choices }] of byCategory)
92
+ rows.push({ id: `category:${key}`, caption, choices })
93
+ // The leftovers are captioned only when something PRECEDES them: as the picker's only row they
94
+ // are the whole catalog, and a heading would then name a distinction the user cannot see.
95
+ if (uncategorized.length > 0)
96
+ rows.push({
97
+ id: 'other',
98
+ caption: rows.length > 0 ? captions.other : null,
99
+ choices: uncategorized,
100
+ })
101
+ return rows
102
+ }
103
+
104
+ /**
105
+ * The bucket a caption falls in: its own text folded on CASE and on whitespace runs, so two
106
+ * spellings of one category ("API delivery" / "api delivery") are ONE row rather than
107
+ * near-duplicate headings sitting beside each other. The row still renders the FIRST-SEEN
108
+ * spelling verbatim, because the caption is the deployment's own words.
109
+ *
110
+ * Deliberately NOT slugified: a caption is arbitrary Unicode (a deployment writes it in its own
111
+ * language), so stripping it to an id-safe `[a-z0-9-]` key would fold genuinely distinct captions
112
+ * onto each other ("Ámbito" and "Émbito" both reduce to `mbito`). That is also why the row id
113
+ * carries the folded caption as-is and is addressed as an attribute VALUE rather than a testid.
114
+ */
115
+ function categoryKey(caption: string): string {
116
+ return caption.toLowerCase().replace(/\s+/g, ' ')
117
+ }
118
+
119
+ /**
120
+ * A registered custom type as a picker choice. The id assertion carries the widened `taskType`
121
+ * contract (`<built-in> | <ns>:<name>`): a namespaced registered id IS a legal create-task value,
122
+ * which is what let `AddTaskModal` offer these choices in the first place.
123
+ */
124
+ function toChoice<T extends string>(type: CustomTaskType): TaskTypePickerChoice<T> {
125
+ const { label, icon, description } = type.presentation
126
+ return { value: type.taskType as T, label, icon, ...(description ? { description } : {}) }
127
+ }
@@ -2600,6 +2600,7 @@
2600
2600
  "title": "Eine Aufgabe hinzufügen",
2601
2601
  "newTaskIn": "Neue Aufgabe in {container}",
2602
2602
  "typeLabel": "Typ",
2603
+ "typeOther": "Sonstige",
2603
2604
  "types": {
2604
2605
  "feature": "Feature",
2605
2606
  "bug": "Bug",
@@ -3680,7 +3681,8 @@
3680
3681
  "connectSourceNamed": "{source} verbinden",
3681
3682
  "empty": "Hängen Sie eine Anforderung, ein RFC oder ein PRD an, damit Agents es beim Umsetzen dieser Aufgabe sehen.",
3682
3683
  "emptyInitiative": "Hängen Sie eine Anforderung, ein RFC oder ein PRD an, damit die Planungsagenten es beim Entwerfen dieser Initiative lesen.",
3683
- "attached": "Dokument angehängt"
3684
+ "attached": "Dokument angehängt",
3685
+ "uploadedHint": "Über die API hochgeladen, daher gibt es keine Quellseite zum Öffnen."
3684
3686
  },
3685
3687
  "templates": {
3686
3688
  "title": "Dokumentvorlagen & Beispiele",
@@ -5200,6 +5202,7 @@
5200
5202
  "input_gate_not_parked": "Nichts zu beantworten",
5201
5203
  "input_gate_parked": "Über die Eingabeprüfung der Aufgabe beantworten",
5202
5204
  "ticket_already_linked": "Dieses Ticket hat bereits eine Aufgabe",
5205
+ "document_already_linked": "Dokument bereits angehängt",
5203
5206
  "dry_run_not_mergeable": "Probelauf kann nicht zusammengeführt werden"
5204
5207
  },
5205
5208
  "description": {
@@ -5236,6 +5239,7 @@
5236
5239
  "input_gate_not_parked": "Dieser Lauf wartet nicht mehr auf seine Eingabeprüfung. Möglicherweise hat sie jemand schon beantwortet oder der Lauf ist weitergelaufen.",
5237
5240
  "input_gate_parked": "Dieser Lauf wartet auf seine Eingabeprüfung, die über die Freigabe nicht beantwortet werden kann. Nutzen Sie den Hinweis am Lauf: Aufgabe ergänzen und erneut prüfen, oder trotzdem ausführen.",
5238
5241
  "ticket_already_linked": "Ein Ticket kann nur eine Aufgabe stützen. Es erneut zu verknüpfen würde der bestehenden Aufgabe genau den Kontext entziehen, mit dem sie angelegt wurde. Öffne stattdessen diese Aufgabe oder hebe die Verknüpfung des Tickets zuerst auf.",
5242
+ "document_already_linked": "Dieses Dokument ist an eine andere Aufgabe angehängt. Lösen Sie es dort zuerst, oder hängen Sie eine separate Kopie an.",
5239
5243
  "dry_run_not_mergeable": "Dieser Pull Request stammt aus einem Probelauf und kann hier nicht zusammengeführt werden. Starte die Aufgabe erneut als echten Lauf, um einen Pull Request zu erzeugen, den dieser Arbeitsbereich zusammenführt."
5240
5244
  },
5241
5245
  "action": {
@@ -216,6 +216,10 @@
216
216
  "title": "Add a task",
217
217
  "newTaskIn": "New task in {container}",
218
218
  "typeLabel": "Type",
219
+ "typeOther": "Other",
220
+ "@typeOther": {
221
+ "description": "Heading over the trailing group in the task-type picker: the deployment's own registered task types that declared no category of their own. A NOUN-like group heading ('the other ones'), not the adjective describing a single type."
222
+ },
219
223
  "types": {
220
224
  "feature": "Feature",
221
225
  "bug": "Bug",
@@ -639,6 +643,7 @@
639
643
  "input_gate_not_parked": "Nothing to answer",
640
644
  "input_gate_parked": "Answer it in the task's input check",
641
645
  "ticket_already_linked": "This issue already has a task",
646
+ "document_already_linked": "Document already attached",
642
647
  "dry_run_not_mergeable": "Dry run cannot be merged"
643
648
  },
644
649
  "description": {
@@ -678,6 +683,7 @@
678
683
  "input_gate_not_parked": "This run is not waiting on its input check any more. Someone may have answered it already, or the run has moved on.",
679
684
  "input_gate_parked": "This run is parked on its input check, which the approval rail cannot answer. Use the notice on the run: fix the task and re-check, or run it anyway.",
680
685
  "ticket_already_linked": "An issue can back only one task, so linking it again would strip the existing task of the context it was created with. Open that task instead, or unlink the issue first.",
686
+ "document_already_linked": "That document is attached to another task. Detach it there first, or attach a separate copy.",
681
687
  "dry_run_not_mergeable": "This pull request came from a dry run, so it can't be merged from here. Start the task again as a live run to produce a pull request this workspace will merge."
682
688
  },
683
689
  "action": {
@@ -4181,7 +4187,8 @@
4181
4187
  "connectSourceNamed": "Connect {source}",
4182
4188
  "empty": "Attach a requirement, RFC or PRD so agents see it while implementing this task.",
4183
4189
  "emptyInitiative": "Attach a requirement, RFC or PRD so the planning agents read it while shaping this initiative.",
4184
- "attached": "Document attached"
4190
+ "attached": "Document attached",
4191
+ "uploadedHint": "Uploaded through the API, so there is no source page to open."
4185
4192
  },
4186
4193
  "templates": {
4187
4194
  "title": "Document templates & examples",
@@ -189,6 +189,7 @@
189
189
  "title": "Añadir una tarea",
190
190
  "newTaskIn": "Nueva tarea en {container}",
191
191
  "typeLabel": "Tipo",
192
+ "typeOther": "Otros",
192
193
  "types": {
193
194
  "feature": "Funcionalidad",
194
195
  "bug": "Error",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "Nada que responder",
577
578
  "input_gate_parked": "Respóndelo en la comprobación de entrada de la tarea",
578
579
  "ticket_already_linked": "Esta incidencia ya tiene una tarea",
580
+ "document_already_linked": "El documento ya está adjunto",
579
581
  "dry_run_not_mergeable": "Una ejecución de prueba no se puede fusionar"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "Esta ejecución ya no espera su comprobación de entrada. Puede que alguien la haya respondido o que la ejecución haya avanzado.",
613
615
  "input_gate_parked": "Esta ejecución está detenida en su comprobación de entrada, que la vía de aprobación no puede resolver. Usa el aviso de la ejecución: corrige la tarea y vuelve a comprobar, o ejecútala de todos modos.",
614
616
  "ticket_already_linked": "Una incidencia solo puede respaldar una tarea, así que volver a vincularla dejaría a la tarea existente sin el contexto con el que se creó. Abre esa tarea o desvincula antes la incidencia.",
617
+ "document_already_linked": "Ese documento está adjunto a otra tarea. Sepáralo allí primero o adjunta una copia aparte.",
615
618
  "dry_run_not_mergeable": "Esta pull request proviene de una ejecución de prueba, así que no se puede fusionar desde aquí. Vuelve a iniciar la tarea como ejecución real para producir una pull request que este espacio de trabajo sí fusionará."
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "Conectar {source}",
4060
4063
  "empty": "Adjunta un requisito, RFC o PRD para que los agentes lo vean mientras implementan esta tarea.",
4061
4064
  "emptyInitiative": "Adjunta un requisito, RFC o PRD para que los agentes de planificación lo lean al redactar esta iniciativa.",
4062
- "attached": "Documento adjuntado"
4065
+ "attached": "Documento adjuntado",
4066
+ "uploadedHint": "Subido a través de la API, por lo que no hay página de origen que abrir."
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "Plantillas y ejemplos de documentos",
@@ -189,6 +189,7 @@
189
189
  "title": "Ajouter une tâche",
190
190
  "newTaskIn": "Nouvelle tâche dans {container}",
191
191
  "typeLabel": "Type",
192
+ "typeOther": "Autres",
192
193
  "types": {
193
194
  "feature": "Fonctionnalité",
194
195
  "bug": "Bug",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "Rien à répondre",
577
578
  "input_gate_parked": "Répondez-y dans la vérification d'entrée de la tâche",
578
579
  "ticket_already_linked": "Ce ticket a déjà une tâche",
580
+ "document_already_linked": "Document déjà joint",
579
581
  "dry_run_not_mergeable": "Une exécution à blanc ne peut pas être fusionnée"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "Cette exécution n'attend plus sa vérification d'entrée. Quelqu'un y a peut-être déjà répondu, ou l'exécution a avancé.",
613
615
  "input_gate_parked": "Cette exécution est en attente de sa vérification d'entrée, à laquelle la validation ne peut pas répondre. Utilisez l'avis sur l'exécution : corrigez la tâche et relancez la vérification, ou exécutez-la quand même.",
614
616
  "ticket_already_linked": "Un ticket ne peut alimenter qu'une seule tâche : le relier à nouveau priverait la tâche existante du contexte avec lequel elle a été créée. Ouvrez plutôt cette tâche, ou dissociez d'abord le ticket.",
617
+ "document_already_linked": "Ce document est joint à une autre tâche. Détachez-le d'abord, ou joignez-en une copie distincte.",
615
618
  "dry_run_not_mergeable": "Cette pull request provient d'une exécution à blanc et ne peut pas être fusionnée ici. Relancez la tâche en exécution réelle pour produire une pull request que cet espace de travail fusionnera."
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "Connecter {source}",
4060
4063
  "empty": "Joignez une exigence, un RFC ou un PRD pour que les agents le voient pendant l'implémentation de cette tâche.",
4061
4064
  "emptyInitiative": "Joignez une exigence, une RFC ou un PRD pour que les agents de planification le lisent en rédigeant cette initiative.",
4062
- "attached": "Document joint"
4065
+ "attached": "Document joint",
4066
+ "uploadedHint": "Envoyé via l'API : aucune page source à ouvrir."
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "Modèles et exemples de documents",
@@ -189,6 +189,7 @@
189
189
  "title": "הוסף משימה",
190
190
  "newTaskIn": "משימה חדשה ב-{container}",
191
191
  "typeLabel": "סוג",
192
+ "typeOther": "אחרים",
192
193
  "types": {
193
194
  "feature": "תכונה",
194
195
  "bug": "באג",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "אין על מה להשיב",
577
578
  "input_gate_parked": "השיבו בבדיקת הקלט של המשימה",
578
579
  "ticket_already_linked": "לכרטיס הזה כבר יש משימה",
580
+ "document_already_linked": "המסמך כבר מצורף",
579
581
  "dry_run_not_mergeable": "לא ניתן למזג הרצת יבש"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "ההרצה כבר לא ממתינה לבדיקת הקלט. ייתכן שמישהו כבר השיב, או שההרצה התקדמה.",
613
615
  "input_gate_parked": "הרצה זו ממתינה לבדיקת הקלט שלה, ומסלול האישור אינו יכול להשיב עליה. השתמשו בהודעה שעל ההרצה: תקנו את המשימה ובדקו שוב, או הריצו בכל זאת.",
614
616
  "ticket_already_linked": "כרטיס יכול לגבות משימה אחת בלבד, ולכן קישור נוסף שלו ישלול מהמשימה הקיימת את ההקשר שאיתו נוצרה. פתחו את המשימה הזו במקום זאת, או בטלו קודם את קישור הכרטיס.",
617
+ "document_already_linked": "המסמך מצורף למשימה אחרת. נתקו אותו שם תחילה, או צרפו עותק נפרד.",
615
618
  "dry_run_not_mergeable": "בקשת המשיכה הזו הגיעה מהרצת יבש, ולכן לא ניתן למזג אותה מכאן. הפעילו את המשימה מחדש כהרצה רגילה כדי ליצור בקשת משיכה שסביבת העבודה הזו תמזג."
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "חבר את {source}",
4060
4063
  "empty": "צרף דרישה, RFC או PRD כדי שהסוכנים יראו אותם בעת מימוש משימה זו.",
4061
4064
  "emptyInitiative": "צרף דרישה, RFC או PRD כדי שסוכני התכנון יקראו אותם בעת גיבוש יוזמה זו.",
4062
- "attached": "המסמך צורף"
4065
+ "attached": "המסמך צורף",
4066
+ "uploadedHint": "הועלה דרך ה-API, ולכן אין דף מקור לפתיחה."
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "תבניות ודוגמאות למסמכים",
@@ -2600,6 +2600,7 @@
2600
2600
  "title": "Aggiungi un'attività",
2601
2601
  "newTaskIn": "Nuova attività in {container}",
2602
2602
  "typeLabel": "Tipo",
2603
+ "typeOther": "Altri",
2603
2604
  "types": {
2604
2605
  "feature": "Feature",
2605
2606
  "bug": "Bug",
@@ -3680,7 +3681,8 @@
3680
3681
  "connectSourceNamed": "Collega {source}",
3681
3682
  "empty": "Allega un requisito, un RFC o un PRD così gli agenti lo vedono durante l'implementazione di questa attività.",
3682
3683
  "emptyInitiative": "Allega un requisito, un RFC o un PRD così gli agenti di pianificazione lo leggono mentre redigono questa iniziativa.",
3683
- "attached": "Documento allegato"
3684
+ "attached": "Documento allegato",
3685
+ "uploadedHint": "Caricato tramite l'API, quindi non c'è una pagina di origine da aprire."
3684
3686
  },
3685
3687
  "templates": {
3686
3688
  "title": "Modelli ed esempi di documenti",
@@ -5200,6 +5202,7 @@
5200
5202
  "input_gate_not_parked": "Niente a cui rispondere",
5201
5203
  "input_gate_parked": "Rispondi nel controllo di input dell'attività",
5202
5204
  "ticket_already_linked": "Questo ticket ha già un'attività",
5205
+ "document_already_linked": "Documento già allegato",
5203
5206
  "dry_run_not_mergeable": "Una prova non può essere unita"
5204
5207
  },
5205
5208
  "description": {
@@ -5236,6 +5239,7 @@
5236
5239
  "input_gate_not_parked": "Questa esecuzione non attende più il controllo dell'input. Forse qualcuno ha già risposto, o l'esecuzione è andata avanti.",
5237
5240
  "input_gate_parked": "Questa esecuzione è in attesa del suo controllo di input, a cui l'approvazione non può rispondere. Usa l'avviso sull'esecuzione: correggi l'attività e ricontrolla, oppure eseguila comunque.",
5238
5241
  "ticket_already_linked": "Un ticket può sostenere una sola attività, quindi ricollegarlo toglierebbe all'attività esistente il contesto con cui è stata creata. Apri invece quell'attività, oppure scollega prima il ticket.",
5242
+ "document_already_linked": "Quel documento è allegato a un'altra attività. Scollegalo prima da lì oppure allega una copia separata.",
5239
5243
  "dry_run_not_mergeable": "Questa pull request proviene da una prova, quindi non può essere unita da qui. Riavvia l’attività come esecuzione reale per produrre una pull request che questo spazio di lavoro unirà."
5240
5244
  },
5241
5245
  "action": {
@@ -189,6 +189,7 @@
189
189
  "title": "タスクを追加",
190
190
  "newTaskIn": "{container} 内の新しいタスク",
191
191
  "typeLabel": "種類",
192
+ "typeOther": "その他",
192
193
  "types": {
193
194
  "feature": "機能",
194
195
  "bug": "バグ",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "応答すべきものはありません",
577
578
  "input_gate_parked": "タスクの入力チェックで回答してください",
578
579
  "ticket_already_linked": "この課題にはすでにタスクがあります",
580
+ "document_already_linked": "ドキュメントは既に添付されています",
579
581
  "dry_run_not_mergeable": "ドライランはマージできません"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "この実行はもう入力チェックを待っていません。すでに誰かが応答したか、実行が先に進んだ可能性があります。",
613
615
  "input_gate_parked": "この実行は入力チェックで停止しており、承認からは回答できません。実行の通知から、タスクを修正して再チェックするか、そのまま実行してください。",
614
616
  "ticket_already_linked": "1 つの課題が支えられるタスクは 1 つだけです。もう一度リンクすると、既存のタスクは作成時の文脈を失います。代わりにそのタスクを開くか、先に課題のリンクを解除してください。",
617
+ "document_already_linked": "そのドキュメントは別のタスクに添付されています。先にそちらで添付を解除するか、別のコピーを添付してください。",
615
618
  "dry_run_not_mergeable": "このプルリクエストはドライランによるものなので、ここからはマージできません。このワークスペースがマージするプルリクエストを作るには、タスクを通常の実行として開始し直してください。"
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "{source} を接続",
4060
4063
  "empty": "要件、RFC、PRD を添付すると、このタスクの実装中にエージェントが参照できます。",
4061
4064
  "emptyInitiative": "要件、RFC、PRD を添付すると、このイニシアチブの計画作成中に計画エージェントが参照できます。",
4062
- "attached": "ドキュメントを添付しました"
4065
+ "attached": "ドキュメントを添付しました",
4066
+ "uploadedHint": "API 経由でアップロードされたため、開けるソースページはありません。"
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "ドキュメントのテンプレートと例",
@@ -189,6 +189,7 @@
189
189
  "title": "Dodaj zadanie",
190
190
  "newTaskIn": "Nowe zadanie w {container}",
191
191
  "typeLabel": "Typ",
192
+ "typeOther": "Inne",
192
193
  "types": {
193
194
  "feature": "Funkcja",
194
195
  "bug": "Błąd",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "Nie ma na co odpowiadać",
577
578
  "input_gate_parked": "Odpowiedz w kontroli danych wejściowych zadania",
578
579
  "ticket_already_linked": "To zgłoszenie ma już zadanie",
580
+ "document_already_linked": "Dokument jest już załączony",
579
581
  "dry_run_not_mergeable": "Uruchomienia próbnego nie można scalić"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "Ten przebieg nie czeka już na kontrolę danych wejściowych. Ktoś mógł już na nią odpowiedzieć albo przebieg poszedł dalej.",
613
615
  "input_gate_parked": "To uruchomienie czeka na kontrolę danych wejściowych, której nie da się rozstrzygnąć przez zatwierdzenie. Skorzystaj z powiadomienia przy uruchomieniu: popraw zadanie i sprawdź ponownie albo uruchom mimo to.",
614
616
  "ticket_already_linked": "Zgłoszenie może stać za tylko jednym zadaniem, więc ponowne powiązanie pozbawiłoby istniejące zadanie kontekstu, z którym powstało. Otwórz to zadanie albo najpierw odłącz zgłoszenie.",
617
+ "document_already_linked": "Ten dokument jest załączony do innego zadania. Najpierw odłącz go tam albo załącz osobną kopię.",
615
618
  "dry_run_not_mergeable": "Ten pull request pochodzi z uruchomienia próbnego, więc nie można go tutaj scalić. Uruchom zadanie ponownie w trybie rzeczywistym, aby powstał pull request, który ta przestrzeń robocza scali."
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "Połącz {source}",
4060
4063
  "empty": "Dołącz wymaganie, dokument RFC lub PRD, aby agenci widzieli je podczas realizacji tego zadania.",
4061
4064
  "emptyInitiative": "Dołącz wymaganie, dokument RFC lub PRD, aby agenci planowania przeczytali je podczas tworzenia planu tej inicjatywy.",
4062
- "attached": "Dokument dołączony"
4065
+ "attached": "Dokument dołączony",
4066
+ "uploadedHint": "Przesłano przez API, więc nie ma strony źródłowej do otwarcia."
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "Szablony i przykłady dokumentów",
@@ -189,6 +189,7 @@
189
189
  "title": "Görev ekle",
190
190
  "newTaskIn": "{container} içinde yeni görev",
191
191
  "typeLabel": "Tür",
192
+ "typeOther": "Diğer",
192
193
  "types": {
193
194
  "feature": "Özellik",
194
195
  "bug": "Hata",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "Yanıtlanacak bir şey yok",
577
578
  "input_gate_parked": "Görevin girdi kontrolünden yanıtlayın",
578
579
  "ticket_already_linked": "Bu kayda ait bir görev zaten var",
580
+ "document_already_linked": "Belge zaten ekli",
579
581
  "dry_run_not_mergeable": "Prova çalışması birleştirilemez"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "Bu çalışma artık giriş denetimini beklemiyor. Biri onu yanıtlamış ya da çalışma ilerlemiş olabilir.",
613
615
  "input_gate_parked": "Bu çalıştırma girdi kontrolünde bekliyor ve onay akışı bunu yanıtlayamaz. Çalıştırmadaki bildirimi kullanın: görevi düzeltip yeniden kontrol edin ya da yine de çalıştırın.",
614
616
  "ticket_already_linked": "Bir kayıt yalnızca tek bir görevi besleyebilir; yeniden bağlamak mevcut görevi oluşturulduğu bağlamdan yoksun bırakır. Bunun yerine o görevi açın ya da önce kaydın bağlantısını kaldırın.",
617
+ "document_already_linked": "Bu belge başka bir göreve ekli. Önce oradan ayırın ya da ayrı bir kopya ekleyin.",
615
618
  "dry_run_not_mergeable": "Bu pull request bir prova çalışmasından geliyor, bu yüzden buradan birleştirilemez. Bu çalışma alanının birleştireceği bir pull request üretmek için görevi gerçek çalışma olarak yeniden başlatın."
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "{source} bağla",
4060
4063
  "empty": "Agentların bu görevi uygularken görebilmesi için bir gereksinim, RFC veya PRD ekle.",
4061
4064
  "emptyInitiative": "Planlama ajanlarının bu girişimin planını hazırlarken okuyabilmesi için bir gereksinim, RFC veya PRD ekle.",
4062
- "attached": "Belge eklendi"
4065
+ "attached": "Belge eklendi",
4066
+ "uploadedHint": "API üzerinden yüklendiği için açılacak bir kaynak sayfa yok."
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "Belge şablonları ve örnekleri",
@@ -189,6 +189,7 @@
189
189
  "title": "Додати завдання",
190
190
  "newTaskIn": "Нове завдання в {container}",
191
191
  "typeLabel": "Тип",
192
+ "typeOther": "Інші",
192
193
  "types": {
193
194
  "feature": "Функція",
194
195
  "bug": "Помилка",
@@ -576,6 +577,7 @@
576
577
  "input_gate_not_parked": "Немає на що відповідати",
577
578
  "input_gate_parked": "Відповідайте в перевірці вхідних даних завдання",
578
579
  "ticket_already_linked": "У цього тікета вже є завдання",
580
+ "document_already_linked": "Документ уже прикріплено",
579
581
  "dry_run_not_mergeable": "Пробний запуск не можна злити"
580
582
  },
581
583
  "description": {
@@ -612,6 +614,7 @@
612
614
  "input_gate_not_parked": "Цей запуск більше не чекає на перевірку вхідних даних. Можливо, хтось уже відповів, або запуск рушив далі.",
613
615
  "input_gate_parked": "Цей запуск очікує на перевірку вхідних даних, і схвалення її не розвʼязує. Скористайтеся повідомленням на запуску: виправте завдання й перевірте ще раз або запустіть попри це.",
614
616
  "ticket_already_linked": "Тікет може живити лише одне завдання, тож повторне звʼязування позбавить наявне завдання контексту, з яким його створено. Відкрийте це завдання або спершу відʼєднайте тікет.",
617
+ "document_already_linked": "Цей документ прикріплено до іншого завдання. Спершу відкріпіть його там або прикріпіть окрему копію.",
615
618
  "dry_run_not_mergeable": "Цей pull request походить із пробного запуску, тому його не можна злити звідси. Запустіть завдання ще раз у звичайному режимі, щоб отримати pull request, який цей робочий простір зіллє."
616
619
  },
617
620
  "action": {
@@ -4059,7 +4062,8 @@
4059
4062
  "connectSourceNamed": "Підключити {source}",
4060
4063
  "empty": "Долучіть вимогу, RFC або PRD, щоб агенти бачили їх під час реалізації цього завдання.",
4061
4064
  "emptyInitiative": "Долучіть вимогу, RFC або PRD, щоб агенти планування прочитали їх під час складання плану цієї ініціативи.",
4062
- "attached": "Документ долучено"
4065
+ "attached": "Документ долучено",
4066
+ "uploadedHint": "Завантажено через API, тож немає вихідної сторінки, яку можна відкрити."
4063
4067
  },
4064
4068
  "templates": {
4065
4069
  "title": "Шаблони та приклади документів",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.219.1",
3
+ "version": "0.221.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -40,7 +40,7 @@
40
40
  "valibot": "^1.4.2",
41
41
  "vue": "3.5.40",
42
42
  "wretch": "^3.0.9",
43
- "@cat-factory/contracts": "0.233.0"
43
+ "@cat-factory/contracts": "0.235.0"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@toad-contracts/testing": "0.3.2",