@cat-factory/app 0.87.5 → 0.89.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/app/components/board/AddTaskModal.vue +70 -2
- package/app/components/board/RecurringPipelineModal.vue +162 -2
- package/app/components/initiative/InitiativeTrackerWindow.vue +249 -11
- package/app/components/panels/MergerResultView.vue +1 -0
- package/app/composables/api/initiative.ts +51 -0
- package/app/stores/initiative.ts +82 -1
- package/app/types/domain.ts +6 -3
- package/app/types/initiative.ts +3 -0
- package/app/types/recurring.ts +1 -0
- package/app/utils/catalog.ts +6 -4
- package/app/utils/initiative.ts +27 -4
- package/i18n/locales/en.json +76 -2
- package/i18n/locales/es.json +79 -2
- package/i18n/locales/fr.json +79 -2
- package/i18n/locales/he.json +73 -2
- package/i18n/locales/ja.json +76 -2
- package/i18n/locales/pl.json +79 -2
- package/i18n/locales/tr.json +76 -2
- package/i18n/locales/uk.json +79 -2
- package/package.json +2 -2
|
@@ -11,8 +11,14 @@
|
|
|
11
11
|
// button is disabled with a hint. Linking needs the block id,
|
|
12
12
|
// so chosen items are staged locally and import-and-linked once the task is created
|
|
13
13
|
// (see useContextLinking) — the same context the agents see for every step of the run.
|
|
14
|
-
import type {
|
|
15
|
-
|
|
14
|
+
import type {
|
|
15
|
+
CreateTaskType,
|
|
16
|
+
DocKind,
|
|
17
|
+
DocKindFieldKey,
|
|
18
|
+
TaskSourceKind,
|
|
19
|
+
TaskTypeFields,
|
|
20
|
+
} from '~/types/domain'
|
|
21
|
+
import { DOC_KINDS, DOC_KIND_FIELDS } from '~/types/domain'
|
|
16
22
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
17
23
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
18
24
|
import { mergePresetOptionLabel, mergePresetThresholds } from '~/utils/mergePreset'
|
|
@@ -97,6 +103,40 @@ const docKind = ref<DocKind | ''>('')
|
|
|
97
103
|
const docAudience = ref('')
|
|
98
104
|
const docTargetPath = ref('')
|
|
99
105
|
const docOutlineHints = ref('')
|
|
106
|
+
// Per-kind specific fields (see DOC_KIND_FIELDS). Held in one keyed record; only the fields
|
|
107
|
+
// for the selected kind are shown and submitted, so a value from a previously-selected kind is
|
|
108
|
+
// never sent. The catalog keys below keep the labels/placeholders i18n and drift-guarded.
|
|
109
|
+
const docKindFieldValues = reactive<Partial<Record<DocKindFieldKey, string>>>({})
|
|
110
|
+
const docKindFields = computed(() => (docKind.value ? (DOC_KIND_FIELDS[docKind.value] ?? []) : []))
|
|
111
|
+
// Exhaustive Record<DocKindFieldKey, key> of catalog keys — the initiative's drift guard for a
|
|
112
|
+
// dynamic enum→key lookup (a missing enum member is a compile error here; a locale that omits a
|
|
113
|
+
// key falls back via `te()` rather than leaking a raw key). Do NOT inline as bare template keys.
|
|
114
|
+
const DOC_FIELD_LABEL_KEYS: Record<DocKindFieldKey, string> = {
|
|
115
|
+
targetUsers: 'board.addTask.docFields.targetUsers.label',
|
|
116
|
+
successMetrics: 'board.addTask.docFields.successMetrics.label',
|
|
117
|
+
alternativesConsidered: 'board.addTask.docFields.alternativesConsidered.label',
|
|
118
|
+
rolloutConcerns: 'board.addTask.docFields.rolloutConcerns.label',
|
|
119
|
+
decisionDrivers: 'board.addTask.docFields.decisionDrivers.label',
|
|
120
|
+
consideredOptions: 'board.addTask.docFields.consideredOptions.label',
|
|
121
|
+
whenToUse: 'board.addTask.docFields.whenToUse.label',
|
|
122
|
+
escalationPath: 'board.addTask.docFields.escalationPath.label',
|
|
123
|
+
researchQuestion: 'board.addTask.docFields.researchQuestion.label',
|
|
124
|
+
optionsToCompare: 'board.addTask.docFields.optionsToCompare.label',
|
|
125
|
+
apiSurface: 'board.addTask.docFields.apiSurface.label',
|
|
126
|
+
}
|
|
127
|
+
const DOC_FIELD_PLACEHOLDER_KEYS: Record<DocKindFieldKey, string> = {
|
|
128
|
+
targetUsers: 'board.addTask.docFields.targetUsers.placeholder',
|
|
129
|
+
successMetrics: 'board.addTask.docFields.successMetrics.placeholder',
|
|
130
|
+
alternativesConsidered: 'board.addTask.docFields.alternativesConsidered.placeholder',
|
|
131
|
+
rolloutConcerns: 'board.addTask.docFields.rolloutConcerns.placeholder',
|
|
132
|
+
decisionDrivers: 'board.addTask.docFields.decisionDrivers.placeholder',
|
|
133
|
+
consideredOptions: 'board.addTask.docFields.consideredOptions.placeholder',
|
|
134
|
+
whenToUse: 'board.addTask.docFields.whenToUse.placeholder',
|
|
135
|
+
escalationPath: 'board.addTask.docFields.escalationPath.placeholder',
|
|
136
|
+
researchQuestion: 'board.addTask.docFields.researchQuestion.placeholder',
|
|
137
|
+
optionsToCompare: 'board.addTask.docFields.optionsToCompare.placeholder',
|
|
138
|
+
apiSurface: 'board.addTask.docFields.apiSurface.placeholder',
|
|
139
|
+
}
|
|
100
140
|
const SEVERITIES = ['low', 'medium', 'high', 'critical'] as const
|
|
101
141
|
|
|
102
142
|
function buildTypeFields(): TaskTypeFields | undefined {
|
|
@@ -121,6 +161,11 @@ function buildTypeFields(): TaskTypeFields | undefined {
|
|
|
121
161
|
if (docAudience.value.trim()) f.audience = docAudience.value.trim()
|
|
122
162
|
if (docTargetPath.value.trim()) f.targetPath = docTargetPath.value.trim()
|
|
123
163
|
if (docOutlineHints.value.trim()) f.outlineHints = docOutlineHints.value.trim()
|
|
164
|
+
// Only the selected kind's fields are read, so a stale value for another kind is dropped.
|
|
165
|
+
for (const spec of docKindFields.value) {
|
|
166
|
+
const value = docKindFieldValues[spec.key]?.trim()
|
|
167
|
+
if (value) f[spec.key] = value
|
|
168
|
+
}
|
|
124
169
|
return Object.keys(f).length ? f : undefined
|
|
125
170
|
}
|
|
126
171
|
return undefined
|
|
@@ -336,6 +381,8 @@ watch(open, (isOpen) => {
|
|
|
336
381
|
docAudience.value = ''
|
|
337
382
|
docTargetPath.value = ''
|
|
338
383
|
docOutlineHints.value = ''
|
|
384
|
+
for (const key of Object.keys(docKindFieldValues) as DocKindFieldKey[])
|
|
385
|
+
delete docKindFieldValues[key]
|
|
339
386
|
mergePresetId.value = ''
|
|
340
387
|
modelPresetId.value = ''
|
|
341
388
|
pipelineId.value = ''
|
|
@@ -610,6 +657,27 @@ async function add() {
|
|
|
610
657
|
class="w-full"
|
|
611
658
|
/>
|
|
612
659
|
</UFormField>
|
|
660
|
+
<!-- Kind-specific fields — only those relevant to the selected docKind are shown. -->
|
|
661
|
+
<UFormField
|
|
662
|
+
v-for="spec in docKindFields"
|
|
663
|
+
:key="spec.key"
|
|
664
|
+
:label="t(DOC_FIELD_LABEL_KEYS[spec.key])"
|
|
665
|
+
:hint="t('board.addTask.optional')"
|
|
666
|
+
>
|
|
667
|
+
<UTextarea
|
|
668
|
+
v-if="spec.multiline"
|
|
669
|
+
v-model="docKindFieldValues[spec.key]"
|
|
670
|
+
:rows="2"
|
|
671
|
+
:placeholder="t(DOC_FIELD_PLACEHOLDER_KEYS[spec.key])"
|
|
672
|
+
class="w-full"
|
|
673
|
+
/>
|
|
674
|
+
<UInput
|
|
675
|
+
v-else
|
|
676
|
+
v-model="docKindFieldValues[spec.key]"
|
|
677
|
+
:placeholder="t(DOC_FIELD_PLACEHOLDER_KEYS[spec.key])"
|
|
678
|
+
class="w-full"
|
|
679
|
+
/>
|
|
680
|
+
</UFormField>
|
|
613
681
|
</div>
|
|
614
682
|
|
|
615
683
|
<div class="grid grid-cols-2 gap-3">
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// task block inside the frame that the schedule re-runs. When the Tech-debt
|
|
6
6
|
// pipeline is picked, the workspace issue-tracker choice is surfaced inline (it is
|
|
7
7
|
// where that pipeline files its ticket) and saved alongside.
|
|
8
|
-
import type { Recurrence, ScheduleTemplate } from '~/types/recurring'
|
|
8
|
+
import type { IssueIntakeConfig, Recurrence, ScheduleTemplate } from '~/types/recurring'
|
|
9
|
+
import type { TaskSourceKind } from '~/types/domain'
|
|
9
10
|
import { pipelineAllowedForSchedule } from '~/utils/pipeline'
|
|
10
11
|
|
|
11
12
|
const ui = useUiStore()
|
|
@@ -13,6 +14,7 @@ const board = useBoardStore()
|
|
|
13
14
|
const pipelines = usePipelinesStore()
|
|
14
15
|
const recurring = useRecurringPipelinesStore()
|
|
15
16
|
const tracker = useTrackerStore()
|
|
17
|
+
const tasks = useTasksStore()
|
|
16
18
|
const toast = useToast()
|
|
17
19
|
const { t } = useI18n()
|
|
18
20
|
|
|
@@ -41,6 +43,17 @@ const trackerKind = ref<'github' | 'jira' | 'linear' | null>(null)
|
|
|
41
43
|
const jiraProjectKey = ref('')
|
|
42
44
|
const linearTeamId = ref('')
|
|
43
45
|
|
|
46
|
+
// Issue-intake config (only relevant when the picked pipeline has a `bug-intake` step). Which
|
|
47
|
+
// tracker board + predicates a recurring bug-triage run pulls its one issue from, per-schedule.
|
|
48
|
+
const intakeSource = ref<TaskSourceKind | null>(null)
|
|
49
|
+
const intakeJiraProjectKey = ref('')
|
|
50
|
+
const intakeLinearTeamId = ref('')
|
|
51
|
+
const intakeGithubRepo = ref('')
|
|
52
|
+
const intakeTitleFragment = ref('')
|
|
53
|
+
const intakeLabels = ref('') // comma-separated in the UI, sent as an array
|
|
54
|
+
const intakeIssueType = ref('')
|
|
55
|
+
const intakeInProgressLabel = ref('')
|
|
56
|
+
|
|
44
57
|
function defaultRecurrence(): Recurrence {
|
|
45
58
|
return {
|
|
46
59
|
intervalHours: 168, // weekly
|
|
@@ -77,6 +90,19 @@ const template = computed<ScheduleTemplate>(() => {
|
|
|
77
90
|
})
|
|
78
91
|
const isTechDebt = computed(() => template.value === 'tech-debt')
|
|
79
92
|
|
|
93
|
+
// A pipeline whose ENABLED steps include `bug-intake` pulls its work from the tracker board, so
|
|
94
|
+
// the intake config is surfaced + required. Mirrors the backend `pipelineHasEnabledBugIntake`
|
|
95
|
+
// (a disabled step imposes nothing), so the modal doesn't demand config for a step that won't run.
|
|
96
|
+
const isBugIntake = computed(() => {
|
|
97
|
+
const pipeline = selectedPipeline.value
|
|
98
|
+
if (!pipeline) return false
|
|
99
|
+
return pipeline.agentKinds.some(
|
|
100
|
+
(kind, i) => kind === 'bug-intake' && pipeline.enabled?.[i] !== false,
|
|
101
|
+
)
|
|
102
|
+
})
|
|
103
|
+
// Sources that can back intake right now (connected / App-installed AND enabled).
|
|
104
|
+
const intakeSources = computed(() => tasks.offeredSources)
|
|
105
|
+
|
|
80
106
|
watch(open, (isOpen) => {
|
|
81
107
|
if (!isOpen) return
|
|
82
108
|
name.value = ''
|
|
@@ -92,9 +118,62 @@ watch(open, (isOpen) => {
|
|
|
92
118
|
trackerKind.value = tracker.settings.tracker
|
|
93
119
|
jiraProjectKey.value = tracker.settings.jiraProjectKey ?? ''
|
|
94
120
|
linearTeamId.value = tracker.settings.linearTeamId ?? ''
|
|
121
|
+
intakeSource.value = null
|
|
122
|
+
intakeJiraProjectKey.value = ''
|
|
123
|
+
intakeLinearTeamId.value = ''
|
|
124
|
+
intakeGithubRepo.value = ''
|
|
125
|
+
intakeTitleFragment.value = ''
|
|
126
|
+
intakeLabels.value = ''
|
|
127
|
+
intakeIssueType.value = ''
|
|
128
|
+
intakeInProgressLabel.value = ''
|
|
129
|
+
// Load the connected task sources so the intake source picker is populated.
|
|
130
|
+
void tasks.probe()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// The board field required for the picked source must be filled before a bug-intake schedule saves.
|
|
134
|
+
const intakeReady = computed(() => {
|
|
135
|
+
if (!isBugIntake.value) return true
|
|
136
|
+
if (intakeSource.value === 'jira') return intakeJiraProjectKey.value.trim().length > 0
|
|
137
|
+
if (intakeSource.value === 'linear') return intakeLinearTeamId.value.trim().length > 0
|
|
138
|
+
if (intakeSource.value === 'github') return intakeGithubRepo.value.trim().length > 0
|
|
139
|
+
return false
|
|
95
140
|
})
|
|
96
141
|
|
|
97
|
-
|
|
142
|
+
function buildIssueIntake(): IssueIntakeConfig {
|
|
143
|
+
const source = intakeSource.value as TaskSourceKind
|
|
144
|
+
const labels = intakeLabels.value
|
|
145
|
+
.split(',')
|
|
146
|
+
.map((l) => l.trim())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
return {
|
|
149
|
+
source,
|
|
150
|
+
board: {
|
|
151
|
+
...(source === 'jira' && intakeJiraProjectKey.value.trim()
|
|
152
|
+
? { jiraProjectKey: intakeJiraProjectKey.value.trim() }
|
|
153
|
+
: {}),
|
|
154
|
+
...(source === 'linear' && intakeLinearTeamId.value.trim()
|
|
155
|
+
? { linearTeamId: intakeLinearTeamId.value.trim() }
|
|
156
|
+
: {}),
|
|
157
|
+
...(source === 'github' && intakeGithubRepo.value.trim()
|
|
158
|
+
? { githubRepo: intakeGithubRepo.value.trim() }
|
|
159
|
+
: {}),
|
|
160
|
+
},
|
|
161
|
+
predicates: {
|
|
162
|
+
...(intakeTitleFragment.value.trim()
|
|
163
|
+
? { titleFragment: intakeTitleFragment.value.trim() }
|
|
164
|
+
: {}),
|
|
165
|
+
...(labels.length ? { labels } : {}),
|
|
166
|
+
...(intakeIssueType.value.trim() ? { issueType: intakeIssueType.value.trim() } : {}),
|
|
167
|
+
},
|
|
168
|
+
...(source === 'github' && intakeInProgressLabel.value.trim()
|
|
169
|
+
? { inProgressLabel: intakeInProgressLabel.value.trim() }
|
|
170
|
+
: {}),
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const canAdd = computed(
|
|
175
|
+
() => name.value.trim().length > 0 && pipelineId.value.length > 0 && intakeReady.value,
|
|
176
|
+
)
|
|
98
177
|
|
|
99
178
|
async function add() {
|
|
100
179
|
const frameId = ui.addRecurringFrameId
|
|
@@ -119,6 +198,7 @@ async function add() {
|
|
|
119
198
|
onDemand: onDemand.value,
|
|
120
199
|
...(onDemand.value ? {} : { recurrence: recurrence.value }),
|
|
121
200
|
...(description.value.trim() ? { description: description.value.trim() } : {}),
|
|
201
|
+
...(isBugIntake.value ? { issueIntake: buildIssueIntake() } : {}),
|
|
122
202
|
})
|
|
123
203
|
ui.closeAddRecurring()
|
|
124
204
|
} catch (e) {
|
|
@@ -238,6 +318,86 @@ async function add() {
|
|
|
238
318
|
</UFormField>
|
|
239
319
|
</div>
|
|
240
320
|
|
|
321
|
+
<div v-if="isBugIntake" class="space-y-3 rounded-lg border border-slate-800 p-3">
|
|
322
|
+
<p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
323
|
+
{{ t('board.recurring.intake') }}
|
|
324
|
+
</p>
|
|
325
|
+
<p class="text-[11px] text-slate-500">
|
|
326
|
+
{{ t('board.recurring.intakeHint') }}
|
|
327
|
+
</p>
|
|
328
|
+
<p v-if="intakeSources.length === 0" class="text-[11px] text-amber-500">
|
|
329
|
+
{{ t('board.recurring.intakeNoSources') }}
|
|
330
|
+
</p>
|
|
331
|
+
<div v-else class="flex flex-wrap gap-1">
|
|
332
|
+
<UButton
|
|
333
|
+
v-for="s in intakeSources"
|
|
334
|
+
:key="s.source"
|
|
335
|
+
size="xs"
|
|
336
|
+
:color="intakeSource === s.source ? 'primary' : 'neutral'"
|
|
337
|
+
:variant="intakeSource === s.source ? 'solid' : 'subtle'"
|
|
338
|
+
:icon="s.icon"
|
|
339
|
+
@click="intakeSource = s.source"
|
|
340
|
+
>
|
|
341
|
+
{{ s.label }}
|
|
342
|
+
</UButton>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<UFormField
|
|
346
|
+
v-if="intakeSource === 'jira'"
|
|
347
|
+
:label="t('board.recurring.jiraProjectKey')"
|
|
348
|
+
required
|
|
349
|
+
>
|
|
350
|
+
<UInput
|
|
351
|
+
v-model="intakeJiraProjectKey"
|
|
352
|
+
:placeholder="t('board.recurring.jiraProjectKeyPlaceholder')"
|
|
353
|
+
class="w-full"
|
|
354
|
+
/>
|
|
355
|
+
</UFormField>
|
|
356
|
+
<UFormField
|
|
357
|
+
v-if="intakeSource === 'linear'"
|
|
358
|
+
:label="t('board.recurring.linearTeamId')"
|
|
359
|
+
required
|
|
360
|
+
>
|
|
361
|
+
<UInput v-model="intakeLinearTeamId" placeholder="team_…" class="w-full" />
|
|
362
|
+
</UFormField>
|
|
363
|
+
<UFormField
|
|
364
|
+
v-if="intakeSource === 'github'"
|
|
365
|
+
:label="t('board.recurring.intakeGithubRepo')"
|
|
366
|
+
required
|
|
367
|
+
>
|
|
368
|
+
<!-- A GitHub repo ref is always the literal `owner/name` path, never localized. -->
|
|
369
|
+
<UInput v-model="intakeGithubRepo" placeholder="owner/name" class="w-full" />
|
|
370
|
+
</UFormField>
|
|
371
|
+
|
|
372
|
+
<template v-if="intakeSource">
|
|
373
|
+
<UFormField :label="t('board.recurring.intakeTitleFragment')">
|
|
374
|
+
<UInput
|
|
375
|
+
v-model="intakeTitleFragment"
|
|
376
|
+
:placeholder="t('board.recurring.intakeTitleFragmentPlaceholder')"
|
|
377
|
+
class="w-full"
|
|
378
|
+
/>
|
|
379
|
+
</UFormField>
|
|
380
|
+
<UFormField :label="t('board.recurring.intakeLabels')">
|
|
381
|
+
<UInput
|
|
382
|
+
v-model="intakeLabels"
|
|
383
|
+
:placeholder="t('board.recurring.intakeLabelsPlaceholder')"
|
|
384
|
+
class="w-full"
|
|
385
|
+
/>
|
|
386
|
+
</UFormField>
|
|
387
|
+
<UFormField :label="t('board.recurring.intakeIssueType')">
|
|
388
|
+
<!-- A literal issue-type example (tracker vocabulary), kept verbatim across locales. -->
|
|
389
|
+
<UInput v-model="intakeIssueType" placeholder="bug" class="w-full" />
|
|
390
|
+
</UFormField>
|
|
391
|
+
<UFormField
|
|
392
|
+
v-if="intakeSource === 'github'"
|
|
393
|
+
:label="t('board.recurring.intakeInProgressLabel')"
|
|
394
|
+
>
|
|
395
|
+
<!-- A literal label example, kept verbatim across locales. -->
|
|
396
|
+
<UInput v-model="intakeInProgressLabel" placeholder="in-progress" class="w-full" />
|
|
397
|
+
</UFormField>
|
|
398
|
+
</template>
|
|
399
|
+
</div>
|
|
400
|
+
|
|
241
401
|
<p class="text-[11px] text-slate-500">
|
|
242
402
|
{{ t('board.recurring.footerHint') }}
|
|
243
403
|
</p>
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
// result-view host: from the board card / inspector (`ui.openInitiativeTracker`) or
|
|
8
8
|
// as the planner step's result view. Live `initiative` stream events patch the
|
|
9
9
|
// store, so an open window follows the plan as it is ingested and later executed.
|
|
10
|
-
import { computed } from 'vue'
|
|
11
|
-
import type { InitiativeItem } from '~/types/domain'
|
|
10
|
+
import { computed, reactive, ref } from 'vue'
|
|
11
|
+
import type { InitiativeFollowUp, InitiativeItem } from '~/types/domain'
|
|
12
12
|
import {
|
|
13
|
+
INITIATIVE_FOLLOWUP_STATUS_CHIPS,
|
|
14
|
+
INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS,
|
|
13
15
|
INITIATIVE_ITEM_STATUS_CHIPS,
|
|
14
16
|
INITIATIVE_ITEM_STATUS_LABEL_KEYS,
|
|
15
17
|
INITIATIVE_STATUS_LABEL_KEYS,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
const board = useBoardStore()
|
|
20
22
|
const initiatives = useInitiativesStore()
|
|
21
23
|
const { t } = useI18n()
|
|
24
|
+
const toast = useToast()
|
|
22
25
|
|
|
23
26
|
const { open, blockId, close } = useResultView('initiative-tracker', {
|
|
24
27
|
onOpen: (id) => void initiatives.load(id),
|
|
@@ -52,6 +55,93 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
52
55
|
].filter((a): a is string => a !== null)
|
|
53
56
|
return axes.length ? axes.join(' · ') : t('initiative.tracker.axisNever')
|
|
54
57
|
}
|
|
58
|
+
|
|
59
|
+
// ---- Curation (slice 4): only meaningful while the initiative is still executing ----
|
|
60
|
+
const editable = computed(() => initiative.value?.status === 'executing')
|
|
61
|
+
|
|
62
|
+
/** Report a failed curation call as a toast (a stale-rev CAS conflict, an illegal edit, …). */
|
|
63
|
+
function reportError(error: unknown) {
|
|
64
|
+
const message = error instanceof Error ? error.message : t('initiative.curation.failed')
|
|
65
|
+
toast.add({ title: t('initiative.curation.failed'), description: message, color: 'error' })
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Follow-up promotion: an inline per-follow-up form (phase + optional title override).
|
|
69
|
+
const promotingId = ref<string | null>(null)
|
|
70
|
+
const promoteForm = reactive<{ phaseId: string; title: string }>({ phaseId: '', title: '' })
|
|
71
|
+
|
|
72
|
+
function startPromote(followUp: InitiativeFollowUp) {
|
|
73
|
+
const sourcePhase = (initiative.value?.items ?? []).find(
|
|
74
|
+
(i) => i.id === followUp.sourceItemId,
|
|
75
|
+
)?.phaseId
|
|
76
|
+
promoteForm.phaseId = sourcePhase ?? phases.value[0]?.id ?? ''
|
|
77
|
+
promoteForm.title = followUp.title
|
|
78
|
+
promotingId.value = followUp.id
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function submitPromote(followUp: InitiativeFollowUp) {
|
|
82
|
+
if (!initiative.value || !promoteForm.phaseId) return
|
|
83
|
+
try {
|
|
84
|
+
await initiatives.promoteFollowUp(initiative.value.id, followUp.id, {
|
|
85
|
+
phaseId: promoteForm.phaseId,
|
|
86
|
+
...(promoteForm.title.trim() && promoteForm.title.trim() !== followUp.title
|
|
87
|
+
? { title: promoteForm.title.trim() }
|
|
88
|
+
: {}),
|
|
89
|
+
})
|
|
90
|
+
promotingId.value = null
|
|
91
|
+
} catch (error) {
|
|
92
|
+
reportError(error)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function dismissFollowUp(followUp: InitiativeFollowUp) {
|
|
97
|
+
if (!initiative.value) return
|
|
98
|
+
try {
|
|
99
|
+
await initiatives.dismissFollowUp(initiative.value.id, followUp.id)
|
|
100
|
+
} catch (error) {
|
|
101
|
+
reportError(error)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Item status control: retry a blocked item, or skip a pending/blocked one.
|
|
106
|
+
async function itemAction(item: InitiativeItem, action: 'retry' | 'skip') {
|
|
107
|
+
if (!initiative.value) return
|
|
108
|
+
try {
|
|
109
|
+
await initiatives.updateItem(initiative.value.id, item.id, { action })
|
|
110
|
+
} catch (error) {
|
|
111
|
+
reportError(error)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Policy editing: retune the two scalar knobs (concurrency + default pipeline) while preserving
|
|
116
|
+
// the planner-authored rules. A full rule editor stays out of scope — re-plan to reshape rules.
|
|
117
|
+
const editingPolicy = ref(false)
|
|
118
|
+
const policyForm = reactive<{ maxConcurrent: number; defaultPipelineId: string }>({
|
|
119
|
+
maxConcurrent: 1,
|
|
120
|
+
defaultPipelineId: '',
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
function startEditPolicy() {
|
|
124
|
+
const policy = initiative.value?.policy
|
|
125
|
+
if (!policy) return
|
|
126
|
+
policyForm.maxConcurrent = policy.maxConcurrent
|
|
127
|
+
policyForm.defaultPipelineId = policy.defaultPipelineId
|
|
128
|
+
editingPolicy.value = true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function savePolicy() {
|
|
132
|
+
const policy = initiative.value?.policy
|
|
133
|
+
if (!initiative.value || !policy) return
|
|
134
|
+
try {
|
|
135
|
+
await initiatives.updatePolicy(initiative.value.id, {
|
|
136
|
+
...policy,
|
|
137
|
+
maxConcurrent: policyForm.maxConcurrent,
|
|
138
|
+
defaultPipelineId: policyForm.defaultPipelineId.trim() || policy.defaultPipelineId,
|
|
139
|
+
})
|
|
140
|
+
editingPolicy.value = false
|
|
141
|
+
} catch (error) {
|
|
142
|
+
reportError(error)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
55
145
|
</script>
|
|
56
146
|
|
|
57
147
|
<template>
|
|
@@ -193,10 +283,34 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
193
283
|
<div v-if="item.note" class="mt-0.5 text-[10px] text-amber-300/80">
|
|
194
284
|
{{ item.note }}
|
|
195
285
|
</div>
|
|
286
|
+
<div
|
|
287
|
+
v-if="
|
|
288
|
+
editable && (item.status === 'blocked' || item.status === 'pending')
|
|
289
|
+
"
|
|
290
|
+
class="mt-1 flex gap-1.5"
|
|
291
|
+
>
|
|
292
|
+
<button
|
|
293
|
+
v-if="item.status === 'blocked'"
|
|
294
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
295
|
+
:disabled="initiatives.curating"
|
|
296
|
+
:data-testid="`initiative-item-retry-${item.id}`"
|
|
297
|
+
@click="itemAction(item, 'retry')"
|
|
298
|
+
>
|
|
299
|
+
{{ t('initiative.curation.retry') }}
|
|
300
|
+
</button>
|
|
301
|
+
<button
|
|
302
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
303
|
+
:disabled="initiatives.curating"
|
|
304
|
+
:data-testid="`initiative-item-skip-${item.id}`"
|
|
305
|
+
@click="itemAction(item, 'skip')"
|
|
306
|
+
>
|
|
307
|
+
{{ t('initiative.curation.skip') }}
|
|
308
|
+
</button>
|
|
309
|
+
</div>
|
|
196
310
|
</td>
|
|
197
311
|
<td class="px-3 py-2 align-top">
|
|
198
312
|
<UBadge
|
|
199
|
-
:color="INITIATIVE_ITEM_STATUS_CHIPS[item.status]
|
|
313
|
+
:color="INITIATIVE_ITEM_STATUS_CHIPS[item.status]"
|
|
200
314
|
variant="subtle"
|
|
201
315
|
size="sm"
|
|
202
316
|
>
|
|
@@ -225,10 +339,20 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
225
339
|
|
|
226
340
|
<!-- Execution policy -->
|
|
227
341
|
<section v-if="initiative.policy" class="mb-4">
|
|
228
|
-
<
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
342
|
+
<div class="mb-1 flex items-center gap-2">
|
|
343
|
+
<h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
344
|
+
{{ t('initiative.tracker.policy') }}
|
|
345
|
+
</h3>
|
|
346
|
+
<button
|
|
347
|
+
v-if="editable && !editingPolicy"
|
|
348
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800"
|
|
349
|
+
data-testid="initiative-policy-edit"
|
|
350
|
+
@click="startEditPolicy"
|
|
351
|
+
>
|
|
352
|
+
{{ t('initiative.curation.edit') }}
|
|
353
|
+
</button>
|
|
354
|
+
</div>
|
|
355
|
+
<ul v-if="!editingPolicy" class="text-[12px] text-slate-300">
|
|
232
356
|
<li>
|
|
233
357
|
{{
|
|
234
358
|
t('initiative.tracker.maxConcurrent', {
|
|
@@ -245,6 +369,45 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
245
369
|
<code class="text-sky-300">{{ initiative.policy.defaultPipelineId }}</code>
|
|
246
370
|
</li>
|
|
247
371
|
</ul>
|
|
372
|
+
<!-- Edit form: the two scalar knobs; planner-authored rules are preserved. -->
|
|
373
|
+
<div v-else class="flex flex-col gap-2 rounded-lg border border-slate-800 p-3">
|
|
374
|
+
<label class="flex items-center gap-2 text-[12px] text-slate-300">
|
|
375
|
+
<span class="w-40">{{ t('initiative.curation.maxConcurrentField') }}</span>
|
|
376
|
+
<input
|
|
377
|
+
v-model.number="policyForm.maxConcurrent"
|
|
378
|
+
type="number"
|
|
379
|
+
min="1"
|
|
380
|
+
max="20"
|
|
381
|
+
class="w-20 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-200"
|
|
382
|
+
data-testid="initiative-policy-max-concurrent"
|
|
383
|
+
/>
|
|
384
|
+
</label>
|
|
385
|
+
<label class="flex items-center gap-2 text-[12px] text-slate-300">
|
|
386
|
+
<span class="w-40">{{ t('initiative.curation.defaultPipelineField') }}</span>
|
|
387
|
+
<input
|
|
388
|
+
v-model="policyForm.defaultPipelineId"
|
|
389
|
+
type="text"
|
|
390
|
+
class="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 font-mono text-[11px] text-slate-200"
|
|
391
|
+
data-testid="initiative-policy-default-pipeline"
|
|
392
|
+
/>
|
|
393
|
+
</label>
|
|
394
|
+
<div class="flex gap-2">
|
|
395
|
+
<button
|
|
396
|
+
class="rounded bg-indigo-600 px-2 py-1 text-[11px] text-white hover:bg-indigo-500 disabled:opacity-50"
|
|
397
|
+
:disabled="initiatives.curating"
|
|
398
|
+
data-testid="initiative-policy-save"
|
|
399
|
+
@click="savePolicy"
|
|
400
|
+
>
|
|
401
|
+
{{ t('initiative.curation.save') }}
|
|
402
|
+
</button>
|
|
403
|
+
<button
|
|
404
|
+
class="rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
|
405
|
+
@click="editingPolicy = false"
|
|
406
|
+
>
|
|
407
|
+
{{ t('initiative.curation.cancel') }}
|
|
408
|
+
</button>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
248
411
|
</section>
|
|
249
412
|
|
|
250
413
|
<!-- Logs -->
|
|
@@ -275,10 +438,85 @@ function ruleAxes(rule: { minComplexity?: number; minRisk?: number; minImpact?:
|
|
|
275
438
|
<h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
276
439
|
{{ t('initiative.tracker.followUps') }}
|
|
277
440
|
</h3>
|
|
278
|
-
<ul class="
|
|
279
|
-
<li
|
|
280
|
-
|
|
281
|
-
|
|
441
|
+
<ul class="flex flex-col gap-2 text-[13px] text-slate-300">
|
|
442
|
+
<li
|
|
443
|
+
v-for="f in initiative.followUps"
|
|
444
|
+
:key="f.id"
|
|
445
|
+
class="rounded-lg border border-slate-800 p-2.5"
|
|
446
|
+
:data-testid="`initiative-followup-${f.id}`"
|
|
447
|
+
>
|
|
448
|
+
<div class="flex items-start gap-2">
|
|
449
|
+
<div class="min-w-0 flex-1">
|
|
450
|
+
<span class="font-medium">{{ f.title }}</span>
|
|
451
|
+
<span v-if="f.detail" class="text-slate-400"> — {{ f.detail }}</span>
|
|
452
|
+
</div>
|
|
453
|
+
<UBadge
|
|
454
|
+
:color="INITIATIVE_FOLLOWUP_STATUS_CHIPS[f.status]"
|
|
455
|
+
variant="subtle"
|
|
456
|
+
size="sm"
|
|
457
|
+
>
|
|
458
|
+
{{ t(INITIATIVE_FOLLOWUP_STATUS_LABEL_KEYS[f.status]) }}
|
|
459
|
+
</UBadge>
|
|
460
|
+
</div>
|
|
461
|
+
<!-- Triage actions for an open follow-up (only while executing) -->
|
|
462
|
+
<div v-if="editable && f.status === 'open'" class="mt-2">
|
|
463
|
+
<div v-if="promotingId === f.id" class="flex flex-col gap-2">
|
|
464
|
+
<label class="flex items-center gap-2 text-[12px]">
|
|
465
|
+
<span class="text-slate-400">{{
|
|
466
|
+
t('initiative.curation.phaseField')
|
|
467
|
+
}}</span>
|
|
468
|
+
<select
|
|
469
|
+
v-model="promoteForm.phaseId"
|
|
470
|
+
class="flex-1 rounded border border-slate-700 bg-slate-950 px-2 py-1 text-slate-200"
|
|
471
|
+
data-testid="initiative-promote-phase"
|
|
472
|
+
>
|
|
473
|
+
<option v-for="p in phases" :key="p.id" :value="p.id">
|
|
474
|
+
{{ p.title }}
|
|
475
|
+
</option>
|
|
476
|
+
</select>
|
|
477
|
+
</label>
|
|
478
|
+
<input
|
|
479
|
+
v-model="promoteForm.title"
|
|
480
|
+
type="text"
|
|
481
|
+
class="rounded border border-slate-700 bg-slate-950 px-2 py-1 text-[12px] text-slate-200"
|
|
482
|
+
:placeholder="t('initiative.curation.itemTitlePlaceholder')"
|
|
483
|
+
data-testid="initiative-promote-title"
|
|
484
|
+
/>
|
|
485
|
+
<div class="flex gap-2">
|
|
486
|
+
<button
|
|
487
|
+
class="rounded bg-indigo-600 px-2 py-1 text-[11px] text-white hover:bg-indigo-500 disabled:opacity-50"
|
|
488
|
+
:disabled="initiatives.curating || !promoteForm.phaseId"
|
|
489
|
+
data-testid="initiative-promote-submit"
|
|
490
|
+
@click="submitPromote(f)"
|
|
491
|
+
>
|
|
492
|
+
{{ t('initiative.curation.promoteConfirm') }}
|
|
493
|
+
</button>
|
|
494
|
+
<button
|
|
495
|
+
class="rounded border border-slate-700 px-2 py-1 text-[11px] text-slate-300 hover:bg-slate-800"
|
|
496
|
+
@click="promotingId = null"
|
|
497
|
+
>
|
|
498
|
+
{{ t('initiative.curation.cancel') }}
|
|
499
|
+
</button>
|
|
500
|
+
</div>
|
|
501
|
+
</div>
|
|
502
|
+
<div v-else class="flex gap-1.5">
|
|
503
|
+
<button
|
|
504
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800"
|
|
505
|
+
data-testid="initiative-followup-promote"
|
|
506
|
+
@click="startPromote(f)"
|
|
507
|
+
>
|
|
508
|
+
{{ t('initiative.curation.promote') }}
|
|
509
|
+
</button>
|
|
510
|
+
<button
|
|
511
|
+
class="rounded border border-slate-700 px-1.5 py-0.5 text-[10px] text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
|
512
|
+
:disabled="initiatives.curating"
|
|
513
|
+
data-testid="initiative-followup-dismiss"
|
|
514
|
+
@click="dismissFollowUp(f)"
|
|
515
|
+
>
|
|
516
|
+
{{ t('initiative.curation.dismiss') }}
|
|
517
|
+
</button>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
282
520
|
</li>
|
|
283
521
|
</ul>
|
|
284
522
|
</section>
|
|
@@ -66,6 +66,7 @@ const REASON_KEYS: Record<MergeDecision['reason'], string> = {
|
|
|
66
66
|
no_rationale: 'panels.mergerResult.reason.no_rationale',
|
|
67
67
|
no_assessment: 'panels.mergerResult.reason.no_assessment',
|
|
68
68
|
merge_failed: 'panels.mergerResult.reason.merge_failed',
|
|
69
|
+
merge_partial: 'panels.mergerResult.reason.merge_partial',
|
|
69
70
|
}
|
|
70
71
|
const OUTCOME_KEYS: Record<MergeDecision['outcome'], string> = {
|
|
71
72
|
auto_merged: 'panels.mergerResult.outcome.auto_merged',
|