@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 +7 -0
- package/app/components/board/AddTaskModal.vue +29 -30
- package/app/components/board/ReviewFrictionDialog.vue +8 -5
- 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/composables/useNavContributions.ts +1 -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/ui/modals.ts +28 -0
- package/app/utils/describeWork.spec.ts +76 -0
- package/app/utils/describeWork.ts +71 -0
- package/app/utils/reviewFriction.ts +47 -0
- package/i18n/locales/de.json +26 -2
- package/i18n/locales/en.json +26 -2
- package/i18n/locales/es.json +26 -2
- package/i18n/locales/fr.json +26 -2
- package/i18n/locales/he.json +26 -2
- package/i18n/locales/it.json +26 -2
- package/i18n/locales/ja.json +26 -2
- package/i18n/locales/pl.json +26 -2
- package/i18n/locales/tr.json +26 -2
- package/i18n/locales/uk.json +26 -2
- package/package.json +1 -1
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
|
-
//
|
|
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
|
|
835
|
-
|
|
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
|
-
|
|
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
|
}
|