@cat-factory/app 0.108.1 → 0.109.2
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/nodes/InitiativeCard.vue +57 -19
- package/app/components/common/ClarificationItem.vue +158 -0
- package/app/components/initiative/InitiativePlanningWindow.vue +56 -21
- package/app/components/panels/inspector/InitiativeInspector.vue +14 -31
- package/app/composables/api/initiative.ts +24 -0
- package/app/composables/useInitiativePlanning.ts +91 -0
- package/app/stores/initiative.ts +46 -0
- package/i18n/locales/de.json +10 -0
- package/i18n/locales/en.json +10 -0
- package/i18n/locales/es.json +10 -0
- package/i18n/locales/fr.json +10 -0
- package/i18n/locales/he.json +10 -0
- package/i18n/locales/it.json +10 -0
- package/i18n/locales/ja.json +10 -0
- package/i18n/locales/pl.json +10 -0
- package/i18n/locales/tr.json +10 -0
- package/i18n/locales/uk.json +10 -0
- package/package.json +2 -2
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
// The board card for an `initiative`-level block (a frame child, like a module):
|
|
3
3
|
// title, the initiative's lifecycle status, and — once a plan is ingested — the
|
|
4
|
-
// item-completion progress.
|
|
5
|
-
// "Run planning"
|
|
6
|
-
//
|
|
4
|
+
// item-completion progress. Mirrors a task card's on-card "Start" affordance: the
|
|
5
|
+
// initiative's equivalent "Run planning" (and, while parked mid-interview, "Answer
|
|
6
|
+
// planning questions") lives right here on the board — the same actions the
|
|
7
|
+
// inspector offers — so starting an initiative isn't hidden behind selecting it.
|
|
8
|
+
// The tracker button opens the dedicated window directly. Draggable within its
|
|
9
|
+
// frame like a task card.
|
|
7
10
|
import type { InitiativeStatus } from '~/types/domain'
|
|
8
11
|
import { useBlockDrag } from '~/composables/useBlockDrag'
|
|
12
|
+
import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
|
|
9
13
|
import {
|
|
10
14
|
INITIATIVE_STATUS_CHIPS,
|
|
11
15
|
INITIATIVE_STATUS_LABEL_KEYS,
|
|
@@ -29,13 +33,21 @@ const progress = computed(() => initiativeProgress(initiative.value?.items))
|
|
|
29
33
|
|
|
30
34
|
const selected = computed(() => ui.selectedBlockId === props.blockId)
|
|
31
35
|
|
|
36
|
+
// The "Run planning" / "Answer planning questions" affordances, shared with the inspector so the
|
|
37
|
+
// board card and inspector can't drift (see {@link useInitiativePlanning}).
|
|
38
|
+
const {
|
|
39
|
+
planningPipeline,
|
|
40
|
+
running,
|
|
41
|
+
awaitingAnswers,
|
|
42
|
+
starting,
|
|
43
|
+
runPlanning,
|
|
44
|
+
openPlanning,
|
|
45
|
+
openTracker,
|
|
46
|
+
} = useInitiativePlanning(() => props.blockId)
|
|
47
|
+
|
|
32
48
|
function select() {
|
|
33
49
|
ui.select(props.blockId)
|
|
34
50
|
}
|
|
35
|
-
function openTracker() {
|
|
36
|
-
ui.select(props.blockId)
|
|
37
|
-
ui.openInitiativeTracker(props.blockId)
|
|
38
|
-
}
|
|
39
51
|
function onHandle(e: PointerEvent) {
|
|
40
52
|
if (block.value) startDrag(block.value, e)
|
|
41
53
|
}
|
|
@@ -65,7 +77,7 @@ function onHandle(e: PointerEvent) {
|
|
|
65
77
|
<div
|
|
66
78
|
data-testid="initiative-card"
|
|
67
79
|
class="cursor-pointer rounded-b-lg border border-indigo-800/60 bg-indigo-950/40 p-3 transition hover:border-indigo-600"
|
|
68
|
-
:class="selected ? 'ring-2 ring-indigo-400/60' : ''"
|
|
80
|
+
:class="[selected ? 'ring-2 ring-indigo-400/60' : '', awaitingAnswers ? 'board-pulse' : '']"
|
|
69
81
|
@click.stop="select"
|
|
70
82
|
>
|
|
71
83
|
<div class="flex items-start justify-between gap-2">
|
|
@@ -91,17 +103,43 @@ function onHandle(e: PointerEvent) {
|
|
|
91
103
|
{{ t('initiative.card.progress', { done: progress.settled, total: progress.total }) }}
|
|
92
104
|
</div>
|
|
93
105
|
</div>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
106
|
+
<div class="nodrag mt-2 flex flex-wrap items-center gap-1">
|
|
107
|
+
<UButton
|
|
108
|
+
v-if="awaitingAnswers"
|
|
109
|
+
data-testid="initiative-card-answer-planning"
|
|
110
|
+
size="xs"
|
|
111
|
+
variant="solid"
|
|
112
|
+
color="primary"
|
|
113
|
+
icon="i-lucide-messages-square"
|
|
114
|
+
@click.stop="openPlanning"
|
|
115
|
+
>
|
|
116
|
+
{{ t('initiative.inspector.answerPlanning') }}
|
|
117
|
+
</UButton>
|
|
118
|
+
<UButton
|
|
119
|
+
v-else
|
|
120
|
+
data-testid="initiative-card-run-planning"
|
|
121
|
+
size="xs"
|
|
122
|
+
variant="soft"
|
|
123
|
+
color="primary"
|
|
124
|
+
icon="i-lucide-play"
|
|
125
|
+
:loading="starting || running"
|
|
126
|
+
:disabled="!planningPipeline || running || starting"
|
|
127
|
+
:title="t('initiative.inspector.runPlanning')"
|
|
128
|
+
@click.stop="runPlanning"
|
|
129
|
+
>
|
|
130
|
+
{{ t('initiative.inspector.runPlanning') }}
|
|
131
|
+
</UButton>
|
|
132
|
+
<UButton
|
|
133
|
+
data-testid="initiative-open-tracker"
|
|
134
|
+
size="xs"
|
|
135
|
+
variant="soft"
|
|
136
|
+
color="neutral"
|
|
137
|
+
icon="i-lucide-list-checks"
|
|
138
|
+
@click.stop="openTracker"
|
|
139
|
+
>
|
|
140
|
+
{{ t('initiative.card.openTracker') }}
|
|
141
|
+
</UButton>
|
|
142
|
+
</div>
|
|
105
143
|
</div>
|
|
106
144
|
</div>
|
|
107
145
|
</template>
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shared "clarification item" — the per-prompt answer surface reused by the initiative-planning
|
|
3
|
+
// window and (incrementally) the requirements-review window, so the two ask/answer/dismiss/recommend
|
|
4
|
+
// UIs are ONE component rather than parallel clones. See docs/initiatives/clarification-items.md.
|
|
5
|
+
//
|
|
6
|
+
// It renders a prompt, an answer textarea, and the common actions (Not relevant / Recommend), plus
|
|
7
|
+
// a dismissed→reopen state and an optional inline AI suggestion with "Use this answer". Window
|
|
8
|
+
// -specific extras (severity/category badges, a window's own recommendation section) ride the
|
|
9
|
+
// `badges` / `actions` slots; the recommend button only EMITS, so each window wires its own
|
|
10
|
+
// recommend mechanism. `dismissed`/`requested` hide the textarea (nothing to answer right now).
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
/** The question / finding headline. */
|
|
13
|
+
prompt: string
|
|
14
|
+
/** Optional longer prose under the prompt (e.g. a requirements finding's detail). */
|
|
15
|
+
detail?: string
|
|
16
|
+
/** The editable answer draft (v-model). Undefined is treated as empty. */
|
|
17
|
+
answer?: string
|
|
18
|
+
/** The human marked this not-relevant → show a chip + Reopen instead of the answer box. */
|
|
19
|
+
dismissed?: boolean
|
|
20
|
+
/** A recommendation is being generated for this item → hide the box, show a working chip. */
|
|
21
|
+
requested?: boolean
|
|
22
|
+
/** An AI-suggested answer to offer inline, or null/undefined for none. */
|
|
23
|
+
recommendation?: string | null
|
|
24
|
+
/** A recommend request is in flight for THIS item (button spinner). */
|
|
25
|
+
recommending?: boolean
|
|
26
|
+
/** Freeze all inputs/actions (settled / background cycle running). */
|
|
27
|
+
disabled?: boolean
|
|
28
|
+
/** Placeholder for the answer box; defaults to the shared clarification placeholder. */
|
|
29
|
+
answerPlaceholder?: string
|
|
30
|
+
/** Show the Recommend action (needs a wired model). Default true. */
|
|
31
|
+
canRecommend?: boolean
|
|
32
|
+
}>()
|
|
33
|
+
|
|
34
|
+
const emit = defineEmits<{
|
|
35
|
+
'update:answer': [value: string]
|
|
36
|
+
persist: []
|
|
37
|
+
dismiss: []
|
|
38
|
+
reopen: []
|
|
39
|
+
recommend: []
|
|
40
|
+
useRecommendation: []
|
|
41
|
+
}>()
|
|
42
|
+
|
|
43
|
+
const { t } = useI18n()
|
|
44
|
+
|
|
45
|
+
const draft = computed({
|
|
46
|
+
get: () => props.answer ?? '',
|
|
47
|
+
set: (value: string) => emit('update:answer', value),
|
|
48
|
+
})
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
<template>
|
|
52
|
+
<div
|
|
53
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
|
|
54
|
+
data-testid="clarification-item"
|
|
55
|
+
>
|
|
56
|
+
<div class="flex items-start justify-between gap-2">
|
|
57
|
+
<p class="text-[13px] font-medium text-slate-200">{{ prompt }}</p>
|
|
58
|
+
<slot name="badges" />
|
|
59
|
+
</div>
|
|
60
|
+
<p v-if="detail" class="mt-1 whitespace-pre-wrap text-[12px] leading-relaxed text-slate-400">
|
|
61
|
+
{{ detail }}
|
|
62
|
+
</p>
|
|
63
|
+
|
|
64
|
+
<!-- dismissed: a "not relevant" chip + reopen -->
|
|
65
|
+
<div v-if="dismissed" class="mt-2 flex items-center justify-between gap-2">
|
|
66
|
+
<span class="inline-flex items-center gap-1 text-[11px] text-slate-500">
|
|
67
|
+
<UIcon name="i-lucide-x" class="h-3.5 w-3.5" />{{ t('clarification.dismissed') }}
|
|
68
|
+
</span>
|
|
69
|
+
<UButton
|
|
70
|
+
size="xs"
|
|
71
|
+
variant="ghost"
|
|
72
|
+
color="neutral"
|
|
73
|
+
icon="i-lucide-rotate-ccw"
|
|
74
|
+
data-testid="clarification-reopen"
|
|
75
|
+
:disabled="disabled"
|
|
76
|
+
@click="emit('reopen')"
|
|
77
|
+
>
|
|
78
|
+
{{ t('clarification.reopen') }}
|
|
79
|
+
</UButton>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<!-- a recommendation is being requested/generated: a working chip, no answer box -->
|
|
83
|
+
<div
|
|
84
|
+
v-else-if="requested"
|
|
85
|
+
class="mt-2 inline-flex items-center gap-1 text-[11px] text-indigo-300"
|
|
86
|
+
data-testid="clarification-requested"
|
|
87
|
+
>
|
|
88
|
+
<UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin" />
|
|
89
|
+
{{ t('clarification.generating') }}
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<template v-else>
|
|
93
|
+
<UTextarea
|
|
94
|
+
v-model="draft"
|
|
95
|
+
:rows="2"
|
|
96
|
+
autoresize
|
|
97
|
+
:disabled="disabled"
|
|
98
|
+
:placeholder="answerPlaceholder ?? t('clarification.answerPlaceholder')"
|
|
99
|
+
class="mt-2 w-full"
|
|
100
|
+
data-testid="clarification-answer"
|
|
101
|
+
@blur="emit('persist')"
|
|
102
|
+
/>
|
|
103
|
+
<div class="mt-2 flex flex-wrap items-center gap-1">
|
|
104
|
+
<UButton
|
|
105
|
+
size="xs"
|
|
106
|
+
variant="soft"
|
|
107
|
+
color="neutral"
|
|
108
|
+
icon="i-lucide-x"
|
|
109
|
+
data-testid="clarification-dismiss"
|
|
110
|
+
:disabled="disabled"
|
|
111
|
+
@click="emit('dismiss')"
|
|
112
|
+
>
|
|
113
|
+
{{ t('clarification.notRelevant') }}
|
|
114
|
+
</UButton>
|
|
115
|
+
<UButton
|
|
116
|
+
v-if="canRecommend !== false"
|
|
117
|
+
size="xs"
|
|
118
|
+
variant="soft"
|
|
119
|
+
color="primary"
|
|
120
|
+
icon="i-lucide-wand-2"
|
|
121
|
+
data-testid="clarification-recommend"
|
|
122
|
+
:loading="recommending"
|
|
123
|
+
:disabled="disabled || recommending"
|
|
124
|
+
@click="emit('recommend')"
|
|
125
|
+
>
|
|
126
|
+
{{ t('clarification.recommend') }}
|
|
127
|
+
</UButton>
|
|
128
|
+
<slot name="actions" />
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<!-- inline AI suggestion + "use this answer" -->
|
|
132
|
+
<div
|
|
133
|
+
v-if="recommendation"
|
|
134
|
+
class="mt-2 rounded-md border border-indigo-800/50 bg-indigo-950/30 p-2"
|
|
135
|
+
data-testid="clarification-recommendation"
|
|
136
|
+
>
|
|
137
|
+
<div
|
|
138
|
+
class="mb-1 flex items-center gap-1 text-[10px] uppercase tracking-wide text-indigo-300"
|
|
139
|
+
>
|
|
140
|
+
<UIcon name="i-lucide-wand-2" class="h-3 w-3" />{{ t('clarification.suggestion') }}
|
|
141
|
+
</div>
|
|
142
|
+
<p class="whitespace-pre-wrap text-[12px] text-slate-200">{{ recommendation }}</p>
|
|
143
|
+
<UButton
|
|
144
|
+
class="mt-1.5"
|
|
145
|
+
size="xs"
|
|
146
|
+
variant="soft"
|
|
147
|
+
color="primary"
|
|
148
|
+
icon="i-lucide-check"
|
|
149
|
+
data-testid="clarification-use-recommendation"
|
|
150
|
+
:disabled="disabled"
|
|
151
|
+
@click="emit('useRecommendation')"
|
|
152
|
+
>
|
|
153
|
+
{{ t('clarification.useSuggestion') }}
|
|
154
|
+
</UButton>
|
|
155
|
+
</div>
|
|
156
|
+
</template>
|
|
157
|
+
</div>
|
|
158
|
+
</template>
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
|
|
9
9
|
// stream events patch the store, so an open window follows the interview as it progresses.
|
|
10
10
|
import { computed, reactive, watch } from 'vue'
|
|
11
|
+
import ClarificationItem from '~/components/common/ClarificationItem.vue'
|
|
11
12
|
import { INITIATIVE_STATUS_LABEL_KEYS } from '~/utils/initiative'
|
|
12
13
|
|
|
13
14
|
const board = useBoardStore()
|
|
@@ -25,7 +26,10 @@ const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.
|
|
|
25
26
|
const questions = computed(() =>
|
|
26
27
|
(initiative.value?.qa ?? []).map((q, i) => ({ ...q, key: q.id ?? `q-${i}` })),
|
|
27
28
|
)
|
|
28
|
-
|
|
29
|
+
/** Questions still needing an answer: not dismissed, and not yet answered (mirrors backend). */
|
|
30
|
+
const pending = computed(() =>
|
|
31
|
+
questions.value.filter((q) => q.status !== 'dismissed' && !(q.answer ?? '').trim()),
|
|
32
|
+
)
|
|
29
33
|
/** The interview converged (or never started with a model): nothing left to answer. */
|
|
30
34
|
const converged = computed(() => initiative.value?.interview?.status === 'done')
|
|
31
35
|
|
|
@@ -43,18 +47,50 @@ watch(
|
|
|
43
47
|
)
|
|
44
48
|
|
|
45
49
|
const resuming = computed(() => initiatives.resuming)
|
|
46
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Continue is meaningful once every pending question has a drafted answer. A dismissed question
|
|
52
|
+
* doesn't count (it was set aside), so an all-dismissed round is trivially "answered".
|
|
53
|
+
*/
|
|
47
54
|
const allAnswered = computed(() => pending.value.every((q) => drafts[q.key]?.trim()))
|
|
48
55
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Persist one answer if its draft differs from what's recorded. A `dismissed` question is skipped:
|
|
58
|
+
* it was set aside (its server answer cleared), and the `flushThen` sweep on continue/proceed must
|
|
59
|
+
* NOT write a stale local draft back to it — that would silently re-answer a not-relevant question
|
|
60
|
+
* and leak it into the converged digest.
|
|
61
|
+
*/
|
|
62
|
+
async function persist(q: {
|
|
63
|
+
id?: string
|
|
64
|
+
key: string
|
|
65
|
+
answer?: string
|
|
66
|
+
status?: 'open' | 'dismissed'
|
|
67
|
+
}) {
|
|
51
68
|
const id = q.id
|
|
52
|
-
if (!id || !blockId.value) return
|
|
69
|
+
if (!id || !blockId.value || q.status === 'dismissed') return
|
|
53
70
|
const next = (drafts[q.key] ?? '').trim()
|
|
54
71
|
if (!next || next === (q.answer ?? '').trim()) return
|
|
55
72
|
await initiatives.answerQuestion(blockId.value, id, next)
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
/** Mark a question not-relevant / reopen it. */
|
|
76
|
+
async function setStatus(q: { id?: string }, status: 'open' | 'dismissed') {
|
|
77
|
+
if (!q.id || !blockId.value) return
|
|
78
|
+
await initiatives.setQuestionStatus(blockId.value, q.id, status)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ask the interviewer to draft a suggested answer for this question. */
|
|
82
|
+
async function recommend(q: { id?: string }) {
|
|
83
|
+
if (!q.id || !blockId.value) return
|
|
84
|
+
await initiatives.recommendAnswer(blockId.value, q.id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Adopt a suggested answer into the draft, then persist it. */
|
|
88
|
+
async function useRecommendation(q: { id?: string; key: string; recommendation?: string | null }) {
|
|
89
|
+
if (!q.recommendation) return
|
|
90
|
+
drafts[q.key] = q.recommendation
|
|
91
|
+
await persist(q)
|
|
92
|
+
}
|
|
93
|
+
|
|
58
94
|
/** Flush all dirty drafts, then run a window action (continue / proceed). */
|
|
59
95
|
async function flushThen(action: (id: string) => Promise<unknown>) {
|
|
60
96
|
if (!blockId.value) return
|
|
@@ -129,23 +165,22 @@ const onProceed = () => flushThen((id) => initiatives.proceedPlanning(id))
|
|
|
129
165
|
{{ t('initiative.planning.converged') }}
|
|
130
166
|
</div>
|
|
131
167
|
|
|
132
|
-
<!-- Interview questions
|
|
168
|
+
<!-- Interview questions — the shared clarification surface (answer / not-relevant /
|
|
169
|
+
recommend), reused with the requirements-review window. -->
|
|
133
170
|
<ul v-else class="space-y-4">
|
|
134
|
-
<li
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
data-testid="initiative-planning-answer"
|
|
148
|
-
@blur="persist(q)"
|
|
171
|
+
<li v-for="q in questions" :key="q.key" data-testid="initiative-planning-question">
|
|
172
|
+
<ClarificationItem
|
|
173
|
+
v-model:answer="drafts[q.key]"
|
|
174
|
+
:prompt="q.question"
|
|
175
|
+
:dismissed="q.status === 'dismissed'"
|
|
176
|
+
:recommendation="q.recommendation"
|
|
177
|
+
:recommending="!!q.id && initiatives.recommending.has(q.id)"
|
|
178
|
+
:answer-placeholder="t('initiative.planning.answerPlaceholder')"
|
|
179
|
+
@persist="persist(q)"
|
|
180
|
+
@dismiss="setStatus(q, 'dismissed')"
|
|
181
|
+
@reopen="setStatus(q, 'open')"
|
|
182
|
+
@recommend="recommend(q)"
|
|
183
|
+
@use-recommendation="useRecommendation(q)"
|
|
149
184
|
/>
|
|
150
185
|
</li>
|
|
151
186
|
</ul>
|
|
@@ -5,47 +5,29 @@
|
|
|
5
5
|
// cancel once executing), and the tracker window opener. Plan/policy editing lands
|
|
6
6
|
// with slice 4.
|
|
7
7
|
import type { Block, InitiativeStatus } from '~/types/domain'
|
|
8
|
+
import { useInitiativePlanning } from '~/composables/useInitiativePlanning'
|
|
8
9
|
import { INITIATIVE_STATUS_LABEL_KEYS, initiativeProgress } from '~/utils/initiative'
|
|
9
10
|
|
|
10
11
|
const props = defineProps<{ block: Block }>()
|
|
11
12
|
|
|
12
13
|
const initiatives = useInitiativesStore()
|
|
13
|
-
const pipelines = usePipelinesStore()
|
|
14
|
-
const execution = useExecutionStore()
|
|
15
|
-
const ui = useUiStore()
|
|
16
14
|
const { t } = useI18n()
|
|
17
15
|
|
|
18
16
|
const initiative = computed(() => initiatives.forBlock(props.block.id))
|
|
19
17
|
|
|
20
18
|
const status = computed<InitiativeStatus>(() => initiative.value?.status ?? 'planning')
|
|
21
19
|
|
|
22
|
-
// The planning
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
// The interviewer has parked the planning run with questions awaiting answers.
|
|
34
|
-
const awaitingAnswers = computed(
|
|
35
|
-
() =>
|
|
36
|
-
initiative.value?.interview?.status === 'awaiting' &&
|
|
37
|
-
(initiative.value?.qa ?? []).some((q) => !(q.answer ?? '').trim()),
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
function runPlanning() {
|
|
41
|
-
if (planningPipeline.value) void execution.start(props.block.id, planningPipeline.value)
|
|
42
|
-
}
|
|
43
|
-
function openTracker() {
|
|
44
|
-
ui.openInitiativeTracker(props.block.id)
|
|
45
|
-
}
|
|
46
|
-
function openPlanning() {
|
|
47
|
-
ui.openInitiativePlanning(props.block.id)
|
|
48
|
-
}
|
|
20
|
+
// The "Run planning" / "Answer planning questions" affordances, shared with the board card so the
|
|
21
|
+
// two surfaces can't drift (see {@link useInitiativePlanning}).
|
|
22
|
+
const {
|
|
23
|
+
planningPipeline,
|
|
24
|
+
running,
|
|
25
|
+
awaitingAnswers,
|
|
26
|
+
starting,
|
|
27
|
+
runPlanning,
|
|
28
|
+
openPlanning,
|
|
29
|
+
openTracker,
|
|
30
|
+
} = useInitiativePlanning(() => props.block.id)
|
|
49
31
|
|
|
50
32
|
const progress = computed(() => initiativeProgress(initiative.value?.items))
|
|
51
33
|
|
|
@@ -90,7 +72,8 @@ function control(action: 'pause' | 'resume' | 'cancel') {
|
|
|
90
72
|
variant="soft"
|
|
91
73
|
size="sm"
|
|
92
74
|
icon="i-lucide-play"
|
|
93
|
-
:
|
|
75
|
+
:loading="starting || running"
|
|
76
|
+
:disabled="!planningPipeline || running || starting"
|
|
94
77
|
@click="runPlanning"
|
|
95
78
|
>
|
|
96
79
|
{{ t('initiative.inspector.runPlanning') }}
|
|
@@ -11,13 +11,16 @@ import {
|
|
|
11
11
|
probeInitiativePresetContract,
|
|
12
12
|
proceedInitiativePlanningContract,
|
|
13
13
|
promoteInitiativeFollowUpContract,
|
|
14
|
+
recommendInitiativeAnswerContract,
|
|
14
15
|
resumeInitiativeContract,
|
|
16
|
+
setInitiativeQuestionStatusContract,
|
|
15
17
|
updateInitiativeItemContract,
|
|
16
18
|
updateInitiativePolicyContract,
|
|
17
19
|
} from '@cat-factory/contracts'
|
|
18
20
|
import type {
|
|
19
21
|
InitiativeExecutionPolicy,
|
|
20
22
|
InitiativePresetInputs,
|
|
23
|
+
InitiativeQaStatus,
|
|
21
24
|
PromoteInitiativeFollowUpInput,
|
|
22
25
|
UpdateInitiativeItemInput,
|
|
23
26
|
} from '@cat-factory/contracts'
|
|
@@ -74,6 +77,27 @@ export function initiativeApi({ send, ws }: ApiContext) {
|
|
|
74
77
|
body: { questionId, answer },
|
|
75
78
|
}),
|
|
76
79
|
|
|
80
|
+
// Mark a planning question not-relevant (`dismissed`) or reopen it, and ask the interviewer to
|
|
81
|
+
// recommend a suggested answer for one question. Both mutate the entity without resuming the run.
|
|
82
|
+
setInitiativeQuestionStatus: (
|
|
83
|
+
workspaceId: string,
|
|
84
|
+
blockId: string,
|
|
85
|
+
questionId: string,
|
|
86
|
+
status: InitiativeQaStatus,
|
|
87
|
+
) =>
|
|
88
|
+
send(setInitiativeQuestionStatusContract, {
|
|
89
|
+
pathPrefix: ws(workspaceId),
|
|
90
|
+
pathParams: { blockId },
|
|
91
|
+
body: { questionId, status },
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
recommendInitiativeAnswer: (workspaceId: string, blockId: string, questionId: string) =>
|
|
95
|
+
send(recommendInitiativeAnswerContract, {
|
|
96
|
+
pathPrefix: ws(workspaceId),
|
|
97
|
+
pathParams: { blockId },
|
|
98
|
+
body: { questionId },
|
|
99
|
+
}),
|
|
100
|
+
|
|
77
101
|
continueInitiativePlanning: (workspaceId: string, blockId: string) =>
|
|
78
102
|
send(continueInitiativePlanningContract, {
|
|
79
103
|
pathPrefix: ws(workspaceId),
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import { useBoardStore } from '~/stores/board'
|
|
3
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
4
|
+
import { useInitiativesStore } from '~/stores/initiative'
|
|
5
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
|
+
import { useUiStore } from '~/stores/ui'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared planning affordances for an `initiative`-level block, used by BOTH the board card
|
|
10
|
+
* (`InitiativeCard`) and the inspector (`InitiativeInspector`) so the two surfaces can never drift
|
|
11
|
+
* on WHICH pipeline "Run planning" starts, WHEN the interview is awaiting the human, or the
|
|
12
|
+
* optimistic start state. Keyed by the anchor block id; every value is reactive to the
|
|
13
|
+
* board/initiative stores. Mirrors the repo's other extracted per-block composables
|
|
14
|
+
* (`useReviewStage`, `useBlockQueries`).
|
|
15
|
+
*/
|
|
16
|
+
export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
17
|
+
const board = useBoardStore()
|
|
18
|
+
const initiatives = useInitiativesStore()
|
|
19
|
+
const pipelines = usePipelinesStore()
|
|
20
|
+
const execution = useExecutionStore()
|
|
21
|
+
const ui = useUiStore()
|
|
22
|
+
|
|
23
|
+
const block = computed(() => board.getBlock(toValue(blockId)))
|
|
24
|
+
const initiative = computed(() => initiatives.forBlock(toValue(blockId)))
|
|
25
|
+
|
|
26
|
+
// The planning pipeline runnable on this block: its preset descriptor's `planningPipelineId`
|
|
27
|
+
// (the generic preset keeps `pl_initiative`). `planningPipelineIdFor` returns null for a named
|
|
28
|
+
// preset that hasn't hydrated, so "Run planning" stays disabled rather than launching the wrong
|
|
29
|
+
// (generic interviewer) pipeline. The engine's runnable guard still enforces that only an
|
|
30
|
+
// initiative-shaped pipeline runs here.
|
|
31
|
+
const planningPipeline = computed(() => {
|
|
32
|
+
const id = initiatives.planningPipelineIdFor(initiative.value)
|
|
33
|
+
return id ? pipelines.pipelines.find((p) => p.id === id) : undefined
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
/** A run already owns this block (its planning run's id lingers on the block). */
|
|
37
|
+
const running = computed(() => !!block.value?.executionId)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The interviewer has PARKED the planning run for the human. Keyed purely on the interview's
|
|
41
|
+
* parked `status` (`awaiting`) — NOT on whether individual questions are still blank — so the
|
|
42
|
+
* "Answer planning questions" affordance stays available even after every question is filled but
|
|
43
|
+
* before the human resumes. Gating on unanswered questions would hide the only path back to the
|
|
44
|
+
* interview window once all are answered, stranding the still-parked run.
|
|
45
|
+
*/
|
|
46
|
+
const awaitingAnswers = computed(() => initiative.value?.interview?.status === 'awaiting')
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
|
|
50
|
+
* pushes the block's `executionId` back. Cleared the moment `running` takes over (success) or the
|
|
51
|
+
* start is refused/cancelled — never left dangling, which would otherwise strand the button
|
|
52
|
+
* spinning once `running` later clears (e.g. after a cancel returns the block to `planned`).
|
|
53
|
+
*/
|
|
54
|
+
const starting = ref(false)
|
|
55
|
+
watch(running, (isRunning) => {
|
|
56
|
+
if (isRunning) starting.value = false
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
async function runPlanning() {
|
|
60
|
+
if (!planningPipeline.value || running.value || starting.value) return
|
|
61
|
+
starting.value = true
|
|
62
|
+
const started = await execution.start(toValue(blockId), planningPipeline.value)
|
|
63
|
+
// On success `running` flips true and the watcher clears `starting`; on refusal/cancel the
|
|
64
|
+
// store surfaces its own toast, so just revert the optimistic state here.
|
|
65
|
+
if (!started) starting.value = false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Open the planning/interview window (selecting the block first so the inspector follows). */
|
|
69
|
+
function openPlanning() {
|
|
70
|
+
const id = toValue(blockId)
|
|
71
|
+
ui.select(id)
|
|
72
|
+
ui.openInitiativePlanning(id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Open the initiative's tracker window (selecting the block first). */
|
|
76
|
+
function openTracker() {
|
|
77
|
+
const id = toValue(blockId)
|
|
78
|
+
ui.select(id)
|
|
79
|
+
ui.openInitiativeTracker(id)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
planningPipeline,
|
|
84
|
+
running,
|
|
85
|
+
awaitingAnswers,
|
|
86
|
+
starting,
|
|
87
|
+
runPlanning,
|
|
88
|
+
openPlanning,
|
|
89
|
+
openTracker,
|
|
90
|
+
}
|
|
91
|
+
}
|
package/app/stores/initiative.ts
CHANGED
|
@@ -181,6 +181,49 @@ export const useInitiativesStore = defineStore('initiatives', () => {
|
|
|
181
181
|
return updated
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/** Question ids the interviewer is currently drafting a recommendation for (window spinner). */
|
|
185
|
+
const recommending = ref<Set<string>>(new Set())
|
|
186
|
+
|
|
187
|
+
/** Mark a planning question not-relevant (`dismissed`) or reopen it (no run resume). */
|
|
188
|
+
async function setQuestionStatus(
|
|
189
|
+
blockId: string,
|
|
190
|
+
questionId: string,
|
|
191
|
+
status: 'open' | 'dismissed',
|
|
192
|
+
) {
|
|
193
|
+
if (!workspace.workspaceId) throw new Error('No active workspace')
|
|
194
|
+
const updated = await api.setInitiativeQuestionStatus(
|
|
195
|
+
workspace.workspaceId,
|
|
196
|
+
blockId,
|
|
197
|
+
questionId,
|
|
198
|
+
status,
|
|
199
|
+
)
|
|
200
|
+
upsert(updated)
|
|
201
|
+
return updated
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Ask the interviewer to recommend a suggested answer for one pending question. Runs the
|
|
206
|
+
* interviewer LLM inline server-side; the returned entity carries the suggestion on the question.
|
|
207
|
+
* Tracks the in-flight id so the window can show a per-question spinner.
|
|
208
|
+
*/
|
|
209
|
+
async function recommendAnswer(blockId: string, questionId: string) {
|
|
210
|
+
if (!workspace.workspaceId) throw new Error('No active workspace')
|
|
211
|
+
recommending.value = new Set(recommending.value).add(questionId)
|
|
212
|
+
try {
|
|
213
|
+
const updated = await api.recommendInitiativeAnswer(
|
|
214
|
+
workspace.workspaceId,
|
|
215
|
+
blockId,
|
|
216
|
+
questionId,
|
|
217
|
+
)
|
|
218
|
+
upsert(updated)
|
|
219
|
+
return updated
|
|
220
|
+
} finally {
|
|
221
|
+
const next = new Set(recommending.value)
|
|
222
|
+
next.delete(questionId)
|
|
223
|
+
recommending.value = next
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
184
227
|
/** Submit the answers and resume the interview (the interviewer re-runs, may ask more). */
|
|
185
228
|
async function continuePlanning(blockId: string) {
|
|
186
229
|
if (!workspace.workspaceId) throw new Error('No active workspace')
|
|
@@ -313,6 +356,7 @@ export const useInitiativesStore = defineStore('initiatives', () => {
|
|
|
313
356
|
resuming,
|
|
314
357
|
controlling,
|
|
315
358
|
curating,
|
|
359
|
+
recommending,
|
|
316
360
|
forBlock,
|
|
317
361
|
presetById,
|
|
318
362
|
planningPipelineIdFor,
|
|
@@ -323,6 +367,8 @@ export const useInitiativesStore = defineStore('initiatives', () => {
|
|
|
323
367
|
probePreset,
|
|
324
368
|
load,
|
|
325
369
|
answerQuestion,
|
|
370
|
+
setQuestionStatus,
|
|
371
|
+
recommendAnswer,
|
|
326
372
|
continuePlanning,
|
|
327
373
|
proceedPlanning,
|
|
328
374
|
control,
|
package/i18n/locales/de.json
CHANGED
|
@@ -3616,6 +3616,16 @@
|
|
|
3616
3616
|
"next": "Weiter",
|
|
3617
3617
|
"done": "Fertig"
|
|
3618
3618
|
},
|
|
3619
|
+
"clarification": {
|
|
3620
|
+
"answerPlaceholder": "Ihre Antwort",
|
|
3621
|
+
"notRelevant": "Nicht relevant",
|
|
3622
|
+
"recommend": "Antwort vorschlagen",
|
|
3623
|
+
"reopen": "Erneut öffnen",
|
|
3624
|
+
"dismissed": "Als nicht relevant markiert",
|
|
3625
|
+
"generating": "Vorschlag wird erstellt…",
|
|
3626
|
+
"suggestion": "Vorgeschlagene Antwort",
|
|
3627
|
+
"useSuggestion": "Diese Antwort verwenden"
|
|
3628
|
+
},
|
|
3619
3629
|
"nav": {
|
|
3620
3630
|
"menu": "Navigationsmenü",
|
|
3621
3631
|
"openMenu": "Menü öffnen",
|
package/i18n/locales/en.json
CHANGED
|
@@ -75,6 +75,16 @@
|
|
|
75
75
|
"next": "Next",
|
|
76
76
|
"done": "Done"
|
|
77
77
|
},
|
|
78
|
+
"clarification": {
|
|
79
|
+
"answerPlaceholder": "Your answer",
|
|
80
|
+
"notRelevant": "Not relevant",
|
|
81
|
+
"recommend": "Recommend an answer",
|
|
82
|
+
"reopen": "Reopen",
|
|
83
|
+
"dismissed": "Marked not relevant",
|
|
84
|
+
"generating": "Drafting a suggestion…",
|
|
85
|
+
"suggestion": "Suggested answer",
|
|
86
|
+
"useSuggestion": "Use this answer"
|
|
87
|
+
},
|
|
78
88
|
"nav": {
|
|
79
89
|
"menu": "Navigation menu",
|
|
80
90
|
"openMenu": "Open menu",
|
package/i18n/locales/es.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "Siguiente",
|
|
67
67
|
"done": "Listo"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "Tu respuesta",
|
|
71
|
+
"notRelevant": "No relevante",
|
|
72
|
+
"recommend": "Recomendar una respuesta",
|
|
73
|
+
"reopen": "Reabrir",
|
|
74
|
+
"dismissed": "Marcada como no relevante",
|
|
75
|
+
"generating": "Redactando una sugerencia…",
|
|
76
|
+
"suggestion": "Respuesta sugerida",
|
|
77
|
+
"useSuggestion": "Usar esta respuesta"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "Menú de navegación",
|
|
71
81
|
"openMenu": "Abrir menú",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "Suivant",
|
|
67
67
|
"done": "Terminé"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "Votre réponse",
|
|
71
|
+
"notRelevant": "Non pertinent",
|
|
72
|
+
"recommend": "Proposer une réponse",
|
|
73
|
+
"reopen": "Rouvrir",
|
|
74
|
+
"dismissed": "Marquée comme non pertinente",
|
|
75
|
+
"generating": "Rédaction d'une suggestion…",
|
|
76
|
+
"suggestion": "Réponse suggérée",
|
|
77
|
+
"useSuggestion": "Utiliser cette réponse"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "Menu de navigation",
|
|
71
81
|
"openMenu": "Ouvrir le menu",
|
package/i18n/locales/he.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "הבא",
|
|
67
67
|
"done": "סיום"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "התשובה שלך",
|
|
71
|
+
"notRelevant": "לא רלוונטי",
|
|
72
|
+
"recommend": "המלץ על תשובה",
|
|
73
|
+
"reopen": "פתח מחדש",
|
|
74
|
+
"dismissed": "סומן כלא רלוונטי",
|
|
75
|
+
"generating": "מנסח הצעה…",
|
|
76
|
+
"suggestion": "תשובה מוצעת",
|
|
77
|
+
"useSuggestion": "השתמש בתשובה זו"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "תפריט ניווט",
|
|
71
81
|
"openMenu": "פתח תפריט",
|
package/i18n/locales/it.json
CHANGED
|
@@ -3616,6 +3616,16 @@
|
|
|
3616
3616
|
"next": "Avanti",
|
|
3617
3617
|
"done": "Fatto"
|
|
3618
3618
|
},
|
|
3619
|
+
"clarification": {
|
|
3620
|
+
"answerPlaceholder": "La tua risposta",
|
|
3621
|
+
"notRelevant": "Non pertinente",
|
|
3622
|
+
"recommend": "Suggerisci una risposta",
|
|
3623
|
+
"reopen": "Riapri",
|
|
3624
|
+
"dismissed": "Contrassegnata come non pertinente",
|
|
3625
|
+
"generating": "Creazione di un suggerimento…",
|
|
3626
|
+
"suggestion": "Risposta suggerita",
|
|
3627
|
+
"useSuggestion": "Usa questa risposta"
|
|
3628
|
+
},
|
|
3619
3629
|
"nav": {
|
|
3620
3630
|
"menu": "Menu di navigazione",
|
|
3621
3631
|
"openMenu": "Apri il menu",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "次へ",
|
|
67
67
|
"done": "完了"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "回答を入力",
|
|
71
|
+
"notRelevant": "関連なし",
|
|
72
|
+
"recommend": "回答を提案",
|
|
73
|
+
"reopen": "再開",
|
|
74
|
+
"dismissed": "関連なしとして設定",
|
|
75
|
+
"generating": "提案を作成中…",
|
|
76
|
+
"suggestion": "提案された回答",
|
|
77
|
+
"useSuggestion": "この回答を使用"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "ナビゲーションメニュー",
|
|
71
81
|
"openMenu": "メニューを開く",
|
package/i18n/locales/pl.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "Dalej",
|
|
67
67
|
"done": "Gotowe"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "Twoja odpowiedź",
|
|
71
|
+
"notRelevant": "Nieistotne",
|
|
72
|
+
"recommend": "Zaproponuj odpowiedź",
|
|
73
|
+
"reopen": "Otwórz ponownie",
|
|
74
|
+
"dismissed": "Oznaczono jako nieistotne",
|
|
75
|
+
"generating": "Tworzenie propozycji…",
|
|
76
|
+
"suggestion": "Sugerowana odpowiedź",
|
|
77
|
+
"useSuggestion": "Użyj tej odpowiedzi"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "Menu nawigacji",
|
|
71
81
|
"openMenu": "Otwórz menu",
|
package/i18n/locales/tr.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "İleri",
|
|
67
67
|
"done": "Bitti"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "Yanıtınız",
|
|
71
|
+
"notRelevant": "İlgili değil",
|
|
72
|
+
"recommend": "Bir yanıt öner",
|
|
73
|
+
"reopen": "Yeniden aç",
|
|
74
|
+
"dismissed": "İlgisiz olarak işaretlendi",
|
|
75
|
+
"generating": "Öneri hazırlanıyor…",
|
|
76
|
+
"suggestion": "Önerilen yanıt",
|
|
77
|
+
"useSuggestion": "Bu yanıtı kullan"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "Gezinme menüsü",
|
|
71
81
|
"openMenu": "Menüyü aç",
|
package/i18n/locales/uk.json
CHANGED
|
@@ -66,6 +66,16 @@
|
|
|
66
66
|
"next": "Далі",
|
|
67
67
|
"done": "Готово"
|
|
68
68
|
},
|
|
69
|
+
"clarification": {
|
|
70
|
+
"answerPlaceholder": "Ваша відповідь",
|
|
71
|
+
"notRelevant": "Не актуально",
|
|
72
|
+
"recommend": "Запропонувати відповідь",
|
|
73
|
+
"reopen": "Відкрити знову",
|
|
74
|
+
"dismissed": "Позначено як не актуальне",
|
|
75
|
+
"generating": "Створення пропозиції…",
|
|
76
|
+
"suggestion": "Запропонована відповідь",
|
|
77
|
+
"useSuggestion": "Використати цю відповідь"
|
|
78
|
+
},
|
|
69
79
|
"nav": {
|
|
70
80
|
"menu": "Меню навігації",
|
|
71
81
|
"openMenu": "Відкрити меню",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.109.2",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"valibot": "^1.4.2",
|
|
35
35
|
"vue": "3.5.39",
|
|
36
36
|
"wretch": "^3.0.9",
|
|
37
|
-
"@cat-factory/contracts": "0.
|
|
37
|
+
"@cat-factory/contracts": "0.121.1"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@toad-contracts/testing": "0.3.2",
|