@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.
@@ -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. Clicking selects the block (the inspector offers
5
- // "Run planning" / "Open tracker"); the tracker button opens the dedicated
6
- // window directly. Draggable within its frame like a task card.
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
- <UButton
95
- class="nodrag mt-2"
96
- data-testid="initiative-open-tracker"
97
- size="xs"
98
- variant="soft"
99
- color="primary"
100
- icon="i-lucide-list-checks"
101
- @click.stop="openTracker"
102
- >
103
- {{ t('initiative.card.openTracker') }}
104
- </UButton>
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
- const pending = computed(() => questions.value.filter((q) => !(q.answer ?? '').trim()))
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
- /** Continue is meaningful once every pending question has a drafted answer. */
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
- /** Persist one answer if its draft differs from what's recorded. */
50
- async function persist(q: { id?: string; key: string; answer?: string }) {
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
- v-for="q in questions"
136
- :key="q.key"
137
- class="rounded-lg border border-slate-800 bg-slate-950/40 p-3"
138
- data-testid="initiative-planning-question"
139
- >
140
- <p class="mb-2 text-[13px] font-medium text-slate-200">{{ q.question }}</p>
141
- <UTextarea
142
- v-model="drafts[q.key]"
143
- :rows="2"
144
- autoresize
145
- :placeholder="t('initiative.planning.answerPlaceholder')"
146
- class="w-full"
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 pipeline runnable on this initiative block: its preset descriptor's
23
- // `planningPipelineId` (a preset picks its own planning pipeline — the generic preset keeps
24
- // `pl_initiative`). `planningPipelineIdFor` returns null for a named preset that hasn't hydrated,
25
- // so "Run planning" stays disabled rather than launching the wrong (generic interviewer) pipeline.
26
- // The engine's runnable guard still enforces that only an initiative-shaped pipeline runs here.
27
- const planningPipeline = computed(() => {
28
- const id = initiatives.planningPipelineIdFor(initiative.value)
29
- return id ? pipelines.pipelines.find((p) => p.id === id) : undefined
30
- })
31
- const running = computed(() => !!props.block.executionId)
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
- :disabled="!planningPipeline || running"
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
+ }
@@ -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,
@@ -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",
@@ -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",
@@ -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ú",
@@ -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",
@@ -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": "פתח תפריט",
@@ -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",
@@ -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": "メニューを開く",
@@ -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",
@@ -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ç",
@@ -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.108.1",
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.119.0"
37
+ "@cat-factory/contracts": "0.121.1"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@toad-contracts/testing": "0.3.2",