@cat-factory/app 0.313.0 → 0.314.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1492,6 +1492,13 @@ example ships in [`deploy/frontend`](https://github.com/kibertoad/cat-factory/tr
1492
1492
  switchers, the `SpendWarningBanner`, and the toolbar (zoom, LOD, decision queue).
1493
1493
  - **Palettes** (`components/palettes`): drag blocks, pipelines and agents onto
1494
1494
  the board.
1495
+ - **Describe new work** (`components/queue/DescribeWorkModal.vue`, `utils/describeWork.ts`): the
1496
+ one-sentence intake, from Create, the palette and the queue header. It files a feature or a bug
1497
+ from a sentence and a service, titled by the first sentence; the overrides sit behind one
1498
+ disclosure, and the merge policy and model preset follow the full form's tier rule
1499
+ (`showOverrideField`), so basic mode and the designer role see the pipeline only.
1500
+ It rebuilds no other kind: "More kinds" and "Attach context" hand the text to `AddTaskModal`,
1501
+ which stays the one home of those rules. Both share `utils/reviewFriction.ts` for the review-debt 409.
1495
1502
  - **Setup checklist** (`components/setup/SetupChecklistPanel.vue`, `utils/setupChecklist.ts`):
1496
1503
  every prerequisite for "a task can run and merge" in one list, for people who can change the
1497
1504
  setup. It adds no probe of its own: each row reads what its banner or startup dialog already
@@ -36,6 +36,7 @@ import FragmentSelector from '~/components/fragments/FragmentSelector.vue'
36
36
  import ReviewSkillQueue from '~/components/skills/ReviewSkillQueue.vue'
37
37
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
38
38
  import { parseConflict } from '~/composables/usePipelineErrorToast'
39
+ import { isReviewDebtConflict, reviewFrictionContext } from '~/utils/reviewFriction'
39
40
  import { apiErrorEnvelope } from '~/composables/api/errors'
40
41
  import type { ReviewTargetReason } from '@cat-factory/contracts'
41
42
  import {
@@ -526,6 +527,8 @@ function defaultPipelineIdFor(type: TaskTypeChoice): string {
526
527
  return offered ? preset : ''
527
528
  }
528
529
 
530
+ // Set while a prefill's type is being seeded with a carried pipeline (see `onModalOpen` below).
531
+ let seedingPrefill = false
529
532
  watch(taskType, (next) => {
530
533
  const custom = customTaskTypes.value.find((tt) => tt.taskType === next)
531
534
  // A custom type owns a fresh field bag on every switch (its descriptors differ per type), seeded
@@ -533,6 +536,10 @@ watch(taskType, (next) => {
533
536
  customFieldValues.value = descriptorFieldDefaults(custom?.fields ?? [])
534
537
  // An unresolvable preset leaves the current selection alone rather than blanking it: a type
535
538
  // switch is an edit to a form the user is already filling in, not a reset.
539
+ if (seedingPrefill) {
540
+ seedingPrefill = false
541
+ return
542
+ }
536
543
  const preset = defaultPipelineIdFor(next)
537
544
  if (preset) pipelineId.value = preset
538
545
  })
@@ -667,13 +674,23 @@ onModalOpen(open, () => {
667
674
  agentConfigValues.value = {}
668
675
  pendingContext.value = []
669
676
  // Seed from a prefill when opened from another surface (e.g. "create task from
670
- // issue" sets the title + stages the issue as linked context). Pipeline / preset
671
- // are intentionally left at their defaults so the user confirms them here.
677
+ // issue" sets the title + stages the issue as linked context). Pipeline / preset stay at their
678
+ // defaults unless the opener carries a choice the person already made (the one-sentence
679
+ // intake's kind and overrides), which the hand-off must not reset.
672
680
  const prefill = ui.addTaskPrefill
673
681
  if (prefill) {
674
682
  if (prefill.title) title.value = prefill.title
675
683
  if (prefill.description) description.value = prefill.description
676
684
  if (prefill.context?.length) pendingContext.value = [...prefill.context]
685
+ if (prefill.taskType && prefill.taskType !== taskType.value) {
686
+ taskType.value = prefill.taskType
687
+ // The type watcher runs after this block and would replace a carried pipeline with the
688
+ // new type's default; a seeded type is not the edit that watcher is for.
689
+ seedingPrefill = !!prefill.pipelineId
690
+ }
691
+ if (prefill.pipelineId) pipelineId.value = prefill.pipelineId
692
+ if (prefill.riskPolicyId) riskPolicyId.value = prefill.riskPolicyId
693
+ if (prefill.modelPresetId) modelPresetId.value = prefill.modelPresetId
677
694
  }
678
695
  documents.loadDocuments().catch(() => {})
679
696
  tasks.loadTasks().catch(() => {})
@@ -831,8 +848,15 @@ async function submitCreate(acknowledgeReviewDebt: boolean) {
831
848
  ui.closeAddTask()
832
849
  } catch (e) {
833
850
  const conflict = parseConflict(e)
834
- if (conflict?.reason === 'review_debt_warn' || conflict?.reason === 'review_debt_blocked') {
835
- openReviewFrictionDialog(conflict)
851
+ if (isReviewDebtConflict(conflict)) {
852
+ ui.openReviewFriction(
853
+ reviewFrictionContext(
854
+ conflict,
855
+ () => void submitCreate(true),
856
+ () => saving.value,
857
+ () => ui.closeAddTask(),
858
+ ),
859
+ )
836
860
  return
837
861
  }
838
862
  const refusal = createRefusalMessage(e)
@@ -883,32 +907,6 @@ function createRefusalMessage(error: unknown): string | null {
883
907
  if (reason === 'task_type_fields_invalid') return t('board.addTask.customFieldsInvalid')
884
908
  return REVIEW_TARGET_MESSAGES[reason as ReviewTargetReason]?.(details) ?? null
885
909
  }
886
-
887
- /** Turn a parsed review-debt friction 409 into the dialog context (see ReviewFrictionDialog.vue). */
888
- function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseConflict>>) {
889
- const details = conflict.details
890
- const rawDebt = Array.isArray(details.debt) ? details.debt : []
891
- const debt = rawDebt.map((d) => {
892
- const row = (d ?? {}) as { blockId?: unknown; title?: unknown; waitingMinutes?: unknown }
893
- return {
894
- blockId: typeof row.blockId === 'string' ? row.blockId : '',
895
- title: typeof row.title === 'string' ? row.title : null,
896
- waitingMinutes: typeof row.waitingMinutes === 'number' ? row.waitingMinutes : 0,
897
- }
898
- })
899
- const isWarn = conflict.reason === 'review_debt_warn'
900
- ui.openReviewFriction({
901
- kind: isWarn ? 'warn' : 'blocked',
902
- reason:
903
- details.friction === 'count' || details.friction === 'stuck' ? details.friction : undefined,
904
- threshold: typeof details.threshold === 'number' ? details.threshold : null,
905
- debt,
906
- onConfirm: isWarn ? () => void submitCreate(true) : null,
907
- // A getter, not a snapshot: the dialog reads it inside a computed so its button spins and
908
- // locks for as long as the retry actually runs (UX-78).
909
- pending: () => saving.value,
910
- })
911
- }
912
910
  </script>
913
911
 
914
912
  <template>
@@ -954,6 +952,7 @@ function openReviewFrictionDialog(conflict: NonNullable<ReturnType<typeof parseC
954
952
  :icon="ty.icon"
955
953
  size="xs"
956
954
  :title="ty.description"
955
+ :aria-pressed="taskType === ty.value"
957
956
  :data-testid="`task-type-${ty.value}`"
958
957
  @click="
959
958
  () => {
@@ -53,17 +53,20 @@ const body = computed(() => {
53
53
  /** Deep-link to a waiting task's block and dismiss the whole friction flow. */
54
54
  function goToBlock(blockId: string) {
55
55
  if (blockId) ui.select(blockId)
56
+ leave()
57
+ }
58
+
59
+ /** Close the friction and the form that raised it. */
60
+ function leave() {
61
+ const onLeave = ctx.value?.onLeave
56
62
  ui.closeReviewFriction()
57
- ui.closeAddTask()
63
+ onLeave?.()
58
64
  }
59
65
 
60
66
  function goReview() {
61
67
  const first = ctx.value?.debt[0]
62
68
  if (first) goToBlock(first.blockId)
63
- else {
64
- ui.closeReviewFriction()
65
- ui.closeAddTask()
66
- }
69
+ else leave()
67
70
  }
68
71
 
69
72
  function createAnyway() {
@@ -21,6 +21,12 @@ const props = withDefaults(
21
21
  * chosen row — and the row is left out entirely.
22
22
  */
23
23
  noneLabel?: string
24
+ /**
25
+ * What the preview pane says about the "none" row. Defaults to the full form's meaning ("no
26
+ * default; you choose when you run it"); a surface whose "none" row means the workspace default
27
+ * names what that default resolves to instead.
28
+ */
29
+ noneHint?: string
24
30
  /**
25
31
  * Default-trigger text while nothing is selected. Defaults to {@link noneLabel}, which reads
26
32
  * correctly when the "none" row IS the empty state; a surface without that row supplies its
@@ -30,7 +36,7 @@ const props = withDefaults(
30
36
  /** Extra classes for the default trigger button (e.g. full-width in the modal). */
31
37
  triggerClass?: string
32
38
  }>(),
33
- { noneLabel: undefined, placeholder: undefined, triggerClass: '' },
39
+ { noneLabel: undefined, noneHint: undefined, placeholder: undefined, triggerClass: '' },
34
40
  )
35
41
 
36
42
  const emit = defineEmits<{ 'update:modelValue': [string] }>()
@@ -59,7 +65,7 @@ const previewPipeline = computed<Pipeline | null>(() => {
59
65
  */
60
66
  const emptyHint = computed(() =>
61
67
  props.noneLabel !== undefined && (hoverId.value ?? props.modelValue) === ''
62
- ? t('pipeline.picker.noneHint')
68
+ ? (props.noneHint ?? t('pipeline.picker.noneHint'))
63
69
  : t('pipeline.picker.hoverHint'),
64
70
  )
65
71
 
@@ -0,0 +1,367 @@
1
+ <script setup lang="ts">
2
+ import PipelinePicker from '~/components/pipeline/PipelinePicker.vue'
3
+ import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
4
+ import { parseConflict } from '~/composables/usePipelineErrorToast'
5
+ import { splitDescribedWork } from '~/utils/describeWork'
6
+ import { isReviewDebtConflict, reviewFrictionContext } from '~/utils/reviewFriction'
7
+ import { pipelineAllowedForManualStart } from '~/utils/pipeline'
8
+ import { showOverrideField } from '~/utils/uiMode'
9
+
10
+ /**
11
+ * The one-sentence intake (issue #2258): what needs doing, which service, feature or bug. Nothing else on the
12
+ * first screen.
13
+ *
14
+ * Every override the full "Add a task" form offers up front (pipeline, merge policy, model
15
+ * preset) has a workspace default, so here they sit behind ONE "Change how this runs" disclosure
16
+ * that names the defaults it would use. Inside it, the merge policy and model preset follow the
17
+ * full form's tier rule (`showOverrideField`): the basic tier and the designer role, whose tier is
18
+ * capped at basic, see the pipeline only.
19
+ *
20
+ * The other task kinds and context attachments are not rebuilt here: "More kinds" and "Attach
21
+ * context" hand the text over to the full form, prefilled, which is the one place those rules
22
+ * live.
23
+ */
24
+ const { t } = useI18n()
25
+ const board = useBoardStore()
26
+ const ui = useUiStore()
27
+ const pipelines = usePipelinesStore()
28
+ const riskPolicies = useRiskPoliciesStore()
29
+ const modelPresets = useModelPresetsStore()
30
+ const access = useWorkspaceAccess()
31
+ const uiMode = useUiModeStore()
32
+ const toast = useActionToast()
33
+ const { present } = usePipelineErrorToast()
34
+ const pipelineFor = useStartPipelineResolver()
35
+ const { startTask } = useStartWithFeedback()
36
+
37
+ const text = ref('')
38
+ const serviceId = ref<string | undefined>(undefined)
39
+ /** The two kinds this dialog offers. Every other kind is the full form's (see "More kinds"). */
40
+ const kind = ref<'feature' | 'bug'>('feature')
41
+ const pipelineId = ref('')
42
+ const riskPolicyId = ref('')
43
+ const modelPresetId = ref('')
44
+ const saving = ref(false)
45
+
46
+ const services = computed(() => board.frames.filter((f) => f.type !== 'document'))
47
+ const serviceItems = computed(() => services.value.map((s) => ({ label: s.title, value: s.id })))
48
+ const service = computed(() => (serviceId.value ? board.getBlock(serviceId.value) : undefined))
49
+
50
+ onModalOpen(
51
+ () => ui.describeWorkOpen,
52
+ () => {
53
+ text.value = ''
54
+ kind.value = 'feature'
55
+ pipelineId.value = ''
56
+ riskPolicyId.value = ''
57
+ modelPresetId.value = ''
58
+ // The service the reader came from, else the only one there is. With several and no context,
59
+ // nothing is preselected: guessing a service once pushed a task into the wrong repository.
60
+ // The opener's service is taken only when this dialog offers it: a document repository is a
61
+ // frame too, and the queue's filter can name one, but it takes no feature or bug.
62
+ const asked = ui.describeWorkServiceId
63
+ serviceId.value =
64
+ asked && services.value.some((s) => s.id === asked)
65
+ ? asked
66
+ : services.value.length === 1
67
+ ? services.value[0]!.id
68
+ : undefined
69
+ },
70
+ )
71
+
72
+ // The ONE choice of a service frame, kept legal while the dialog is open: a frame deleted under
73
+ // it clears the selection rather than leaving a dead id that a submit would fail on. Nothing is
74
+ // picked in its place, by the rule above.
75
+ watch(services, (next) => {
76
+ if (serviceId.value && !next.some((s) => s.id === serviceId.value)) serviceId.value = undefined
77
+ })
78
+
79
+ // UX-18, as the full form: prompt before discarding typed input on Escape, backdrop or Cancel.
80
+ // Registered after the reset above, so the baseline is the freshly opened dialog.
81
+ const { requestClose } = useUnsavedGuard({
82
+ open: computed(() => ui.describeWorkOpen),
83
+ close: () => ui.closeDescribeWork(),
84
+ saving: () => saving.value,
85
+ snapshot: () => ({
86
+ text: text.value.trim(),
87
+ serviceId: serviceId.value ?? null,
88
+ kind: kind.value,
89
+ pipelineId: pipelineId.value,
90
+ riskPolicyId: riskPolicyId.value,
91
+ modelPresetId: modelPresetId.value,
92
+ }),
93
+ })
94
+ // Declared after the guard so the setter's `requestClose` reference is never in its TDZ.
95
+ const open = computed({
96
+ get: () => ui.describeWorkOpen,
97
+ set: (v: boolean) => {
98
+ if (!v) void requestClose()
99
+ },
100
+ })
101
+
102
+ const split = computed(() => splitDescribedWork(text.value))
103
+ const canSubmit = computed(
104
+ () => !!split.value.title && !!service.value && !saving.value && access.canWriteBoard.value,
105
+ )
106
+
107
+ const selectablePipelines = computed(() =>
108
+ pipelines.pipelines.filter((p) =>
109
+ pipelineAllowedForManualStart(p, service.value, board.blocks, kind.value, 'task'),
110
+ ),
111
+ )
112
+ // A pipeline picked for one kind or service may not be allowed for the next one. The picker then
113
+ // no longer lists it, so the task must not be created pinned to it either.
114
+ watch(selectablePipelines, (next) => {
115
+ if (pipelineId.value && !next.some((p) => p.id === pipelineId.value)) pipelineId.value = ''
116
+ })
117
+
118
+ /** The full form's tier rule for the two overrides basic mode hides (`showOverrideField`). */
119
+ const showRiskPolicy = computed(() => showOverrideField(uiMode.isAdvanced, riskPolicyId.value))
120
+ const showModelPreset = computed(() => showOverrideField(uiMode.isAdvanced, modelPresetId.value))
121
+
122
+ /** What "Workspace default" starts, so the picker does not say there is no default. */
123
+ const defaultPipelineName = computed(() => pipelineFor({ pipelineId: undefined })?.name)
124
+
125
+ /** What each override resolves to when left empty, for the disclosure's one-line summary. */
126
+ const summary = computed(() => {
127
+ const pipeline = pipelineId.value
128
+ ? pipelines.getPipeline(pipelineId.value)?.name
129
+ : defaultPipelineName.value
130
+ const policy = !showRiskPolicy.value
131
+ ? undefined
132
+ : riskPolicyId.value
133
+ ? riskPolicies.presets.find((p) => p.id === riskPolicyId.value)?.name
134
+ : riskPolicies.defaultPreset?.name
135
+ const preset = !showModelPreset.value
136
+ ? undefined
137
+ : modelPresetId.value
138
+ ? modelPresets.presets.find((p) => p.id === modelPresetId.value)?.name
139
+ : modelPresets.defaultPreset?.name
140
+ return [pipeline, policy, preset].filter(Boolean).join(' · ')
141
+ })
142
+ const overridesOpen = ref(false)
143
+
144
+ const KIND_LABEL_KEYS: Record<'feature' | 'bug', string> = {
145
+ feature: 'describeWork.kind.feature',
146
+ bug: 'describeWork.kind.bug',
147
+ }
148
+
149
+ // A select item cannot carry an empty value, so "the workspace default" has its own sentinel
150
+ // and maps back to the empty string the create call reads as "no override".
151
+ const DEFAULT_PRESET = '__workspace_default__'
152
+ const modelPresetItems = computed(() => [
153
+ { label: t('describeWork.workspaceDefault'), value: DEFAULT_PRESET },
154
+ ...modelPresets.presets.map((p) => ({ label: p.name, value: p.id })),
155
+ ])
156
+ const modelPresetValue = computed({
157
+ get: () => modelPresetId.value || DEFAULT_PRESET,
158
+ set: (v: string) => (modelPresetId.value = v === DEFAULT_PRESET ? '' : v),
159
+ })
160
+
161
+ /** Hand the text to the full form, for a kind or an attachment this dialog does not offer. */
162
+ function openFullForm() {
163
+ if (!service.value) return
164
+ const { title, description } = split.value
165
+ // Nothing is discarded: the text and every choice move to the full form, so this closes without
166
+ // the guard.
167
+ ui.closeDescribeWork()
168
+ ui.openAddTask(service.value.id, {
169
+ title,
170
+ description,
171
+ taskType: kind.value,
172
+ ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
173
+ ...(riskPolicyId.value ? { riskPolicyId: riskPolicyId.value } : {}),
174
+ ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
175
+ })
176
+ }
177
+
178
+ async function submit(startNow: boolean, acknowledgeReviewDebt = false) {
179
+ if (!service.value || !split.value.title || saving.value) return
180
+ saving.value = true
181
+ try {
182
+ const { title, description } = split.value
183
+ const block = await board.addTask(service.value.id, title, description, {
184
+ taskType: kind.value,
185
+ ...(pipelineId.value ? { pipelineId: pipelineId.value } : {}),
186
+ ...(riskPolicyId.value ? { riskPolicyId: riskPolicyId.value } : {}),
187
+ ...(modelPresetId.value ? { modelPresetId: modelPresetId.value } : {}),
188
+ ...(acknowledgeReviewDebt ? { acknowledgeReviewDebt: true } : {}),
189
+ })
190
+ // No block means the add did not land (the store has said why); the dialog stays open with
191
+ // the text in it.
192
+ if (!block) return
193
+ ui.closeReviewFriction()
194
+ ui.closeDescribeWork()
195
+ // The card's own Start, with its feedback (started, sandboxed, no pipeline). A start that did
196
+ // not happen still added the task, so the reader is told it is in the queue under "Not
197
+ // started".
198
+ const started = startNow ? await startTask(block) : false
199
+ if (!started) {
200
+ toast.success('describeWork.addedToast', { params: { title: block.title } })
201
+ }
202
+ } catch (e) {
203
+ const conflict = parseConflict(e)
204
+ if (isReviewDebtConflict(conflict)) {
205
+ ui.openReviewFriction(
206
+ reviewFrictionContext(
207
+ conflict,
208
+ () => void submit(startNow, true),
209
+ () => saving.value,
210
+ () => ui.closeDescribeWork(),
211
+ ),
212
+ )
213
+ return
214
+ }
215
+ present(e, 'board.addTask.addFailedTitle')
216
+ } finally {
217
+ saving.value = false
218
+ }
219
+ }
220
+ </script>
221
+
222
+ <template>
223
+ <UModal v-model:open="open" :title="t('describeWork.title')" :ui="{ content: 'sm:max-w-2xl' }">
224
+ <template #body>
225
+ <div class="space-y-4" data-testid="describe-work-modal">
226
+ <UFormField :label="t('describeWork.textLabel')" :description="t('describeWork.textHint')">
227
+ <UTextarea
228
+ v-model="text"
229
+ :rows="5"
230
+ autoresize
231
+ autofocus
232
+ class="w-full"
233
+ :placeholder="t('describeWork.textPlaceholder')"
234
+ data-testid="describe-work-text"
235
+ />
236
+ </UFormField>
237
+ <p v-if="split.title" class="-mt-2 text-2xs text-dimmed" data-testid="describe-work-title">
238
+ {{ t('describeWork.savedAs', { title: split.title }) }}
239
+ </p>
240
+
241
+ <div class="grid gap-3 sm:grid-cols-2">
242
+ <UFormField :label="t('describeWork.serviceLabel')">
243
+ <USelect
244
+ v-model="serviceId"
245
+ :items="serviceItems"
246
+ :placeholder="t('describeWork.servicePlaceholder')"
247
+ class="w-full"
248
+ data-testid="describe-work-service"
249
+ />
250
+ </UFormField>
251
+ <UFormField :label="t('describeWork.kindLabel')">
252
+ <div class="flex items-center gap-1">
253
+ <UButton
254
+ v-for="k in ['feature', 'bug'] as const"
255
+ :key="k"
256
+ size="sm"
257
+ :color="kind === k ? 'primary' : 'neutral'"
258
+ :variant="kind === k ? 'soft' : 'ghost'"
259
+ :data-testid="`describe-work-kind-${k}`"
260
+ @click="kind = k"
261
+ >
262
+ {{ t(KIND_LABEL_KEYS[k]) }}
263
+ </UButton>
264
+ <UButton
265
+ size="sm"
266
+ color="neutral"
267
+ variant="link"
268
+ :disabled="!service"
269
+ data-testid="describe-work-more-kinds"
270
+ @click="openFullForm"
271
+ >
272
+ {{ t('describeWork.moreKinds') }}
273
+ </UButton>
274
+ </div>
275
+ </UFormField>
276
+ </div>
277
+
278
+ <USeparator type="dashed" />
279
+
280
+ <UCollapsible v-model:open="overridesOpen">
281
+ <UButton
282
+ color="neutral"
283
+ variant="link"
284
+ class="p-0"
285
+ :icon="overridesOpen ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
286
+ data-testid="describe-work-overrides"
287
+ >
288
+ <span>{{ t('describeWork.changeHowItRuns') }}</span>
289
+ <span class="text-dimmed">({{ summary || t('describeWork.workspaceDefault') }})</span>
290
+ </UButton>
291
+ <template #content>
292
+ <div
293
+ class="mt-3 grid gap-3"
294
+ :class="showRiskPolicy ? 'sm:grid-cols-3' : 'sm:grid-cols-1'"
295
+ >
296
+ <UFormField :label="t('board.addTask.pipeline')">
297
+ <PipelinePicker
298
+ :model-value="pipelineId"
299
+ :options="selectablePipelines"
300
+ :none-label="t('describeWork.workspaceDefault')"
301
+ :none-hint="
302
+ defaultPipelineName
303
+ ? t('describeWork.defaultPipelineHint', { name: defaultPipelineName })
304
+ : undefined
305
+ "
306
+ trigger-class="w-full justify-between"
307
+ @update:model-value="pipelineId = $event"
308
+ />
309
+ </UFormField>
310
+ <UFormField v-if="showRiskPolicy" :label="t('board.addTask.mergePolicy')">
311
+ <RiskPolicyPicker
312
+ :model-value="riskPolicyId"
313
+ :options="riskPolicies.presets"
314
+ :default-policy="riskPolicies.defaultPreset"
315
+ :none-label="t('describeWork.workspaceDefault')"
316
+ trigger-class="w-full justify-between"
317
+ @update:model-value="riskPolicyId = $event"
318
+ />
319
+ </UFormField>
320
+ <UFormField v-if="showModelPreset" :label="t('board.addTask.modelPreset')">
321
+ <USelect v-model="modelPresetValue" :items="modelPresetItems" class="w-full" />
322
+ </UFormField>
323
+ </div>
324
+ </template>
325
+ </UCollapsible>
326
+
327
+ <UButton
328
+ color="neutral"
329
+ variant="link"
330
+ class="p-0"
331
+ icon="i-lucide-paperclip"
332
+ :disabled="!service"
333
+ @click="openFullForm"
334
+ >
335
+ {{ t('describeWork.attachContext') }}
336
+ </UButton>
337
+ </div>
338
+ </template>
339
+
340
+ <template #footer>
341
+ <div class="flex w-full flex-wrap items-center justify-end gap-2">
342
+ <UButton color="neutral" variant="ghost" @click="requestClose()">
343
+ {{ t('common.cancel') }}
344
+ </UButton>
345
+ <UButton
346
+ color="neutral"
347
+ variant="outline"
348
+ :disabled="!canSubmit"
349
+ :loading="saving"
350
+ data-testid="describe-work-add"
351
+ @click="submit(false)"
352
+ >
353
+ {{ t('describeWork.add') }}
354
+ </UButton>
355
+ <UButton
356
+ :disabled="!canSubmit || !access.canExecuteRuns.value"
357
+ :loading="saving"
358
+ icon="i-lucide-play"
359
+ data-testid="describe-work-add-start"
360
+ @click="submit(true)"
361
+ >
362
+ {{ t('describeWork.addAndStart') }}
363
+ </UButton>
364
+ </div>
365
+ </template>
366
+ </UModal>
367
+ </template>
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { NAV_CONTRIBUTIONS, navItemVisible } from '~/modular/nav-contributions'
3
+ import { createNavGates } from '~/modular/nav-gates'
2
4
  import type { QueueEntry } from '~/utils/queueSections'
3
5
  import QueueCard from '~/components/queue/QueueCard.vue'
4
6
  import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
@@ -68,6 +70,12 @@ const COLUMN_ACCENT: Record<Column['id'], string> = {
68
70
  ready_to_merge: 'text-success',
69
71
  }
70
72
 
73
+ // The header button is the nav entry's twin, so it follows that entry's visibility rule (board
74
+ // write, and a service to put the task in) rather than a copy of it.
75
+ const navGates = createNavGates()
76
+ const describeWorkEntry = NAV_CONTRIBUTIONS.find((c) => c.id === 'describe-work')!
77
+ const canDescribeWork = computed(() => navItemVisible(describeWorkEntry, navGates))
78
+
71
79
  /**
72
80
  * Whether the inspector is on screen beside the queue. On a wide screen it floats over the
73
81
  * board's right edge (`InspectorPanel`, `lg:w-80` at `lg:end-4`), which is where the queue's
@@ -106,6 +114,16 @@ const inspectorOpen = computed(() => !!ui.selectedBlockId && !!board.getBlock(ui
106
114
  <!-- The inbox still holds what is NOT a task: platform alerts, budget thresholds, key
107
115
  drift. The canvas shows it in its toolbar, which this view does not have. -->
108
116
  <NotificationsInbox />
117
+ <!-- The one-sentence intake, opened on the service the queue is filtered to, if any. -->
118
+ <UButton
119
+ v-if="canDescribeWork"
120
+ icon="i-lucide-plus"
121
+ size="sm"
122
+ data-testid="queue-describe-work"
123
+ @click="ui.openDescribeWork(queueStore.activeFilter)"
124
+ >
125
+ {{ t('queue.describeWork') }}
126
+ </UButton>
109
127
  </div>
110
128
  </header>
111
129
 
@@ -73,6 +73,7 @@ export function useNavContributions() {
73
73
  // The QUESTION, not a toggle: with three roles there is no unambiguous "next one", and the
74
74
  // prompt is the one surface that states what each role gives you before you pick it.
75
75
  chooseRole: () => useUiRoleStore().openPrompt(),
76
+ describeWork: () => ui.openDescribeWork(),
76
77
  queueView: () => useWorkViewStore().show('queue'),
77
78
  boardView: () => useWorkViewStore().show('board'),
78
79
  }