@cat-factory/app 0.313.0 → 0.315.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 +7 -0
- package/app/components/board/AddTaskModal.vue +29 -30
- package/app/components/board/ReviewFrictionDialog.vue +8 -5
- package/app/components/layout/NotificationsInbox.vue +62 -129
- package/app/components/pipeline/PipelinePicker.vue +8 -2
- package/app/components/queue/DescribeWorkModal.vue +367 -0
- package/app/components/queue/QueueView.vue +18 -0
- package/app/components/settings/ApiTokensPanel.vue +86 -7
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +4 -0
- package/app/composables/useTaskActions.ts +51 -28
- package/app/modular/nav-contributions.spec.ts +1 -0
- package/app/modular/nav-contributions.ts +21 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/publicApiKeys.spec.ts +28 -0
- package/app/stores/publicApiKeys.ts +17 -3
- package/app/stores/ui/modals.ts +28 -0
- package/app/utils/describeWork.spec.ts +76 -0
- package/app/utils/describeWork.ts +71 -0
- package/app/utils/notificationRoutes.spec.ts +81 -0
- package/app/utils/notificationRoutes.ts +179 -0
- package/app/utils/reviewFriction.ts +47 -0
- package/i18n/locales/de.json +50 -7
- package/i18n/locales/en.json +50 -7
- package/i18n/locales/es.json +50 -7
- package/i18n/locales/fr.json +50 -7
- package/i18n/locales/he.json +50 -7
- package/i18n/locales/it.json +50 -7
- package/i18n/locales/ja.json +50 -7
- package/i18n/locales/pl.json +50 -7
- package/i18n/locales/tr.json +50 -7
- package/i18n/locales/uk.json +50 -7
- package/package.json +2 -2
|
@@ -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
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// API access tokens
|
|
3
|
-
// `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are
|
|
4
|
-
// so the raw secret is shown EXACTLY ONCE, on create; the list thereafter
|
|
5
|
-
// only (label + created / last-used). To rotate a token, revoke it and mint a new
|
|
6
|
-
// Opened from the Integrations hub.
|
|
2
|
+
// API access tokens: the account's inbound public-API keys that reach this workspace, which
|
|
3
|
+
// external systems present to the `/api/v1` surface (`Authorization: Bearer cf_live_…`). Keys are
|
|
4
|
+
// hashed one-way server-side, so the raw secret is shown EXACTLY ONCE, on create; the list thereafter
|
|
5
|
+
// renders metadata only (label + created / last-used). To rotate a token, revoke it and mint a new
|
|
6
|
+
// one. Opened from the Integrations hub.
|
|
7
7
|
import { computed, ref, watch } from 'vue'
|
|
8
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
8
9
|
import type { PublicApiKey, PublicApiScope } from '~/types/publicApiKeys'
|
|
9
10
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
10
11
|
import CopyButton from '~/components/common/CopyButton.vue'
|
|
@@ -36,6 +37,50 @@ const scopeItems = computed(() => SCOPES.map((value) => ({ value, label: scopeLa
|
|
|
36
37
|
const ui = useUiStore()
|
|
37
38
|
const auth = useAuthStore()
|
|
38
39
|
const store = usePublicApiKeysStore()
|
|
40
|
+
const accounts = useAccountsStore()
|
|
41
|
+
const workspace = useWorkspaceStore()
|
|
42
|
+
const uiMode = useUiModeStore()
|
|
43
|
+
|
|
44
|
+
// A key's reach. The default is this board alone, which is what every key was before keys could
|
|
45
|
+
// span workspaces; the wider choices are an override only an account admin can make, so the field
|
|
46
|
+
// is hidden for everyone else rather than offered and refused.
|
|
47
|
+
type TokenReach = 'this' | 'all' | 'selected'
|
|
48
|
+
const reach = ref<TokenReach>('this')
|
|
49
|
+
const selectedWorkspaceIds = ref<string[]>([])
|
|
50
|
+
const canWidenReach = computed(
|
|
51
|
+
() =>
|
|
52
|
+
accounts.activeAccount?.roles?.includes('admin') === true &&
|
|
53
|
+
showOverrideField(uiMode.isAdvanced),
|
|
54
|
+
)
|
|
55
|
+
const reachItems = computed(() => [
|
|
56
|
+
{ value: 'this' as const, label: t('settings.apiTokens.add.reachThis') },
|
|
57
|
+
{ value: 'all' as const, label: t('settings.apiTokens.add.reachAll') },
|
|
58
|
+
{ value: 'selected' as const, label: t('settings.apiTokens.add.reachSelected') },
|
|
59
|
+
])
|
|
60
|
+
const workspaceItems = computed(() =>
|
|
61
|
+
workspace.accountWorkspaces.map((w) => ({ value: w.id, label: w.name })),
|
|
62
|
+
)
|
|
63
|
+
/** The `workspaceIds` to mint with: omitted for this board, `null` for all, or the picked list. */
|
|
64
|
+
function requestedReach(): string[] | null | undefined {
|
|
65
|
+
if (!canWidenReach.value || reach.value === 'this') return undefined
|
|
66
|
+
if (reach.value === 'all') return null
|
|
67
|
+
return selectedWorkspaceIds.value
|
|
68
|
+
}
|
|
69
|
+
// Gated on `canWidenReach` like `requestedReach`: a hidden picker left on 'selected' must not
|
|
70
|
+
// disable the create button.
|
|
71
|
+
const reachIncomplete = computed(
|
|
72
|
+
() =>
|
|
73
|
+
canWidenReach.value && reach.value === 'selected' && selectedWorkspaceIds.value.length === 0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** The reach badge for a listed key; none for a key reaching this one board. */
|
|
77
|
+
function reachLabel(key: PublicApiKey): string | null {
|
|
78
|
+
if (key.workspaceIds === null) return t('settings.apiTokens.list.reachAll')
|
|
79
|
+
if (key.workspaceIds.length > 1) {
|
|
80
|
+
return t('settings.apiTokens.list.reachCount', { count: key.workspaceIds.length })
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
39
84
|
|
|
40
85
|
/**
|
|
41
86
|
* The minter to attribute a key to. When the minter is the signed-in user we show a localized
|
|
@@ -61,7 +106,7 @@ function minterLabel(key: PublicApiKey): string | null {
|
|
|
61
106
|
/**
|
|
62
107
|
* The badge for a key bound to a person's subscription, WHOSE person named.
|
|
63
108
|
*
|
|
64
|
-
*
|
|
109
|
+
* This list shows every key reaching the board, so a colleague's bound key is right here in
|
|
65
110
|
* everyone's panel: a fixed "your subscription" tells every other member that a token they never
|
|
66
111
|
* minted reaches theirs, which is alarming and false. The same comparison {@link minterLabel} makes
|
|
67
112
|
* is available (a binding is always to the minter), so the honest badge names the owner, and falls
|
|
@@ -147,11 +192,14 @@ async function createToken() {
|
|
|
147
192
|
trimmed,
|
|
148
193
|
scope.value,
|
|
149
194
|
canBindSelf.value && identity.value === 'self',
|
|
195
|
+
requestedReach(),
|
|
150
196
|
)
|
|
151
197
|
newSecret.value = created.secret
|
|
152
198
|
label.value = ''
|
|
153
199
|
scope.value = 'write'
|
|
154
200
|
identity.value = 'system'
|
|
201
|
+
reach.value = 'this'
|
|
202
|
+
selectedWorkspaceIds.value = []
|
|
155
203
|
actionToast.success('settings.apiTokens.toast.created')
|
|
156
204
|
} catch (e) {
|
|
157
205
|
present(e, 'settings.apiTokens.toast.createFailed')
|
|
@@ -245,6 +293,15 @@ async function revokeToken(key: PublicApiKey) {
|
|
|
245
293
|
<!-- Always shown, never mode-gated: an existing key can already carry the
|
|
246
294
|
binding, and this is the only place its holder can see that a token they
|
|
247
295
|
are about to hand out reaches a personal subscription. -->
|
|
296
|
+
<UBadge
|
|
297
|
+
v-if="reachLabel(key)"
|
|
298
|
+
color="info"
|
|
299
|
+
variant="subtle"
|
|
300
|
+
size="sm"
|
|
301
|
+
:data-testid="`api-token-reach-${key.id}`"
|
|
302
|
+
>
|
|
303
|
+
{{ reachLabel(key) }}
|
|
304
|
+
</UBadge>
|
|
248
305
|
<UBadge
|
|
249
306
|
v-if="boundLabel(key)"
|
|
250
307
|
color="warning"
|
|
@@ -326,9 +383,31 @@ async function revokeToken(key: PublicApiKey) {
|
|
|
326
383
|
data-testid="api-token-identity"
|
|
327
384
|
/>
|
|
328
385
|
</UFormField>
|
|
386
|
+
<UFormField
|
|
387
|
+
v-if="canWidenReach"
|
|
388
|
+
:label="t('settings.apiTokens.add.reach')"
|
|
389
|
+
:help="t('settings.apiTokens.add.reachHelp')"
|
|
390
|
+
>
|
|
391
|
+
<USelect
|
|
392
|
+
v-model="reach"
|
|
393
|
+
:items="reachItems"
|
|
394
|
+
class="w-full"
|
|
395
|
+
data-testid="api-token-reach"
|
|
396
|
+
/>
|
|
397
|
+
<USelectMenu
|
|
398
|
+
v-if="reach === 'selected'"
|
|
399
|
+
v-model="selectedWorkspaceIds"
|
|
400
|
+
:items="workspaceItems"
|
|
401
|
+
value-key="value"
|
|
402
|
+
multiple
|
|
403
|
+
:placeholder="t('settings.apiTokens.add.reachPick')"
|
|
404
|
+
class="mt-2 w-full"
|
|
405
|
+
data-testid="api-token-reach-workspaces"
|
|
406
|
+
/>
|
|
407
|
+
</UFormField>
|
|
329
408
|
<UButton
|
|
330
409
|
:loading="busy"
|
|
331
|
-
:disabled="!label.trim()"
|
|
410
|
+
:disabled="!label.trim() || reachIncomplete"
|
|
332
411
|
data-testid="api-token-create"
|
|
333
412
|
@click="createToken"
|
|
334
413
|
>
|
|
@@ -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
|
}
|
|
@@ -183,6 +183,10 @@ const CONFLICT_INFO: Record<Exclude<ConflictReason, BespokeConflictReason>, Conf
|
|
|
183
183
|
titleKey: 'errors.conflict.title.session_stale',
|
|
184
184
|
descriptionKey: 'errors.conflict.description.session_stale',
|
|
185
185
|
},
|
|
186
|
+
cursor_expired: {
|
|
187
|
+
titleKey: 'errors.conflict.title.cursor_expired',
|
|
188
|
+
descriptionKey: 'errors.conflict.description.cursor_expired',
|
|
189
|
+
},
|
|
186
190
|
task_limit_reached: {
|
|
187
191
|
titleKey: 'errors.conflict.title.task_limit_reached',
|
|
188
192
|
descriptionKey: 'errors.conflict.description.task_limit_reached',
|
|
@@ -43,6 +43,7 @@ export function useTaskActions(taskId: () => string) {
|
|
|
43
43
|
|
|
44
44
|
const pipelineFor = useStartPipelineResolver()
|
|
45
45
|
const defaultPipeline = computed(() => pipelineFor(task.value))
|
|
46
|
+
const { startTask } = useStartWithFeedback()
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Whether the outcome summary has something to show, asked of the SAME reduction the window
|
|
@@ -77,35 +78,12 @@ export function useTaskActions(taskId: () => string) {
|
|
|
77
78
|
})
|
|
78
79
|
return
|
|
79
80
|
}
|
|
80
|
-
const
|
|
81
|
-
if (!
|
|
82
|
-
toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
|
|
83
|
-
return
|
|
84
|
-
}
|
|
81
|
+
const block = task.value
|
|
82
|
+
if (!block) return
|
|
85
83
|
starting.value = true
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
const started = await execution.start(taskId(), pipeline)
|
|
90
|
-
if (started) {
|
|
91
|
-
// Confirm the (optimistic) start landed: the button unmounts once the stream pushes
|
|
92
|
-
// in_progress, so without this the successful action gives no feedback. A sandboxed start
|
|
93
|
-
// says so here rather than borrowing the live wording: this is the moment the reader learns
|
|
94
|
-
// what they just started, and the two runs differ in what they will end up doing.
|
|
95
|
-
// A sandboxed run is `warning`: it ran, and its outcome differs in a way the reader should
|
|
96
|
-
// know (it opens a pull request and merges nothing), so it stays until closed.
|
|
97
|
-
if (sandboxed.value) {
|
|
98
|
-
toast.warning('board.dryRunToast.title', {
|
|
99
|
-
description: t('board.dryRunToast.body', { name: pipeline.name }),
|
|
100
|
-
})
|
|
101
|
-
} else {
|
|
102
|
-
toast.success('board.task.startedToast.title', {
|
|
103
|
-
description: t('board.task.startedToast.body', { name: pipeline.name }),
|
|
104
|
-
})
|
|
105
|
-
}
|
|
106
|
-
} else {
|
|
107
|
-
starting.value = false
|
|
108
|
-
}
|
|
84
|
+
// On success the button unmounts once the stream pushes in_progress; otherwise revert the
|
|
85
|
+
// optimistic state (`startTask` has said why).
|
|
86
|
+
if (!(await startTask(block))) starting.value = false
|
|
109
87
|
}
|
|
110
88
|
|
|
111
89
|
async function merge() {
|
|
@@ -199,6 +177,51 @@ export function useTaskActions(taskId: () => string) {
|
|
|
199
177
|
}
|
|
200
178
|
}
|
|
201
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Start a task on the pipeline a plain "Start" uses, and tell the person what happened: the
|
|
182
|
+
* card's Start and the intake's "Add and start" both go through it, so the two say the same thing.
|
|
183
|
+
* Resolves false when nothing started (no pipeline to run, a cancelled credential prompt, or a
|
|
184
|
+
* refused start, which `execution.start` reports itself).
|
|
185
|
+
*/
|
|
186
|
+
export function useStartWithFeedback() {
|
|
187
|
+
const execution = useExecutionStore()
|
|
188
|
+
const toast = useActionToast()
|
|
189
|
+
const { t } = useI18n()
|
|
190
|
+
const { forcedFor } = useDryRunPolicy()
|
|
191
|
+
const pipelineFor = useStartPipelineResolver()
|
|
192
|
+
|
|
193
|
+
async function startTask(block: Pick<Block, 'id' | 'pipelineId'>): Promise<boolean> {
|
|
194
|
+
const pipeline = pipelineFor(block)
|
|
195
|
+
if (!pipeline) {
|
|
196
|
+
// A refusal decided in the browser, in translated copy: the `error` tone.
|
|
197
|
+
toast.error('board.task.noPipelineTitle', { description: t('board.task.noPipelineBody') })
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
// Read before the start: the sandbox policy is a fact about this task and role now.
|
|
201
|
+
const sandboxed = forcedFor(block.id)
|
|
202
|
+
const started = await execution.start(block.id, pipeline)
|
|
203
|
+
if (started) {
|
|
204
|
+
// Confirm the start landed: without this the action gives no feedback until the stream
|
|
205
|
+
// moves the card. A sandboxed start says so rather than borrowing the live wording: this is
|
|
206
|
+
// the moment the reader learns what they just started, and the two runs end differently.
|
|
207
|
+
// A sandboxed run is `warning`: it ran, and it ends differently (it opens a pull request and
|
|
208
|
+
// merges nothing), so the toast stays until closed.
|
|
209
|
+
if (sandboxed) {
|
|
210
|
+
toast.warning('board.dryRunToast.title', {
|
|
211
|
+
description: t('board.dryRunToast.body', { name: pipeline.name }),
|
|
212
|
+
})
|
|
213
|
+
} else {
|
|
214
|
+
toast.success('board.task.startedToast.title', {
|
|
215
|
+
description: t('board.task.startedToast.body', { name: pipeline.name }),
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return started
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { startTask }
|
|
223
|
+
}
|
|
224
|
+
|
|
202
225
|
/** The pipeline a plain "Start" runs, as the descriptor `execution.start` takes. */
|
|
203
226
|
export interface StartPipeline {
|
|
204
227
|
id: string
|
|
@@ -224,6 +224,7 @@ describe('navSlotFilter', () => {
|
|
|
224
224
|
tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
|
|
225
225
|
'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
|
|
226
226
|
'ui-role': 'the way BACK out of the narrowed role',
|
|
227
|
+
'describe-work': 'filing a task from one sentence is the job the intake role exists for',
|
|
227
228
|
'queue-view': 'which view of the board a person works in is their own choice, in every role',
|
|
228
229
|
'board-view': 'the way back to the canvas from the queue view, in every role',
|
|
229
230
|
}
|