@cat-factory/app 0.179.0 → 0.181.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/app/components/documents/TaskContextDocs.vue +20 -4
- package/app/components/initiative/InitiativePlanningWindow.vue +149 -76
- package/app/components/initiative/InitiativeTrackerWindow.vue +408 -366
- package/app/components/tasks/BugHuntModal.vue +91 -9
- package/app/components/tasks/ContextIssuePicker.vue +1 -1
- package/app/components/tasks/TaskContextIssues.vue +22 -4
- package/app/composables/useResultViewRunMeta.spec.ts +74 -0
- package/app/composables/useResultViewRunMeta.ts +98 -0
- package/app/docs/consumer-extensions.md +11 -10
- package/app/modular/panels/inspector.logic.spec.ts +12 -5
- package/app/modular/panels/inspector.logic.ts +14 -5
- package/app/utils/initiative.spec.ts +81 -2
- package/app/utils/initiative.ts +32 -0
- package/app/{components/tasks/ContextIssuePicker.logic.spec.ts → utils/taskSources.spec.ts} +16 -5
- package/app/{components/tasks/ContextIssuePicker.logic.ts → utils/taskSources.ts} +13 -11
- package/i18n/locales/de.json +7 -1
- package/i18n/locales/en.json +7 -1
- package/i18n/locales/es.json +7 -1
- package/i18n/locales/fr.json +7 -1
- package/i18n/locales/he.json +7 -1
- package/i18n/locales/it.json +7 -1
- package/i18n/locales/ja.json +7 -1
- package/i18n/locales/pl.json +7 -1
- package/i18n/locales/tr.json +7 -1
- package/i18n/locales/uk.json +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -148,7 +148,10 @@ example ships in [`deploy/frontend`](../../deploy/frontend) (the `acme:security`
|
|
|
148
148
|
- **Context attachments** (`components/context`) — `ContextAttachmentFields`, the
|
|
149
149
|
shared staged-attachment form used by both the add-task and create-initiative
|
|
150
150
|
modals. Picks are held locally and import-and-linked once the block exists (see
|
|
151
|
-
`composables/useContextLinking`), because linking needs a block id.
|
|
151
|
+
`composables/useContextLinking`), because linking needs a block id. Both hosts
|
|
152
|
+
attach to the SAME per-block linkage, so the inspector's `TaskContextDocs` /
|
|
153
|
+
`TaskContextIssues` sections render for a task AND an initiative — an initiative's
|
|
154
|
+
attachments would otherwise be invisible the moment the create modal closed.
|
|
152
155
|
- **Integrations** — modals/panels for `github` (the source-control panel, shared
|
|
153
156
|
by every VCS provider), `vcs` (the GitLab personal-access-token connect),
|
|
154
157
|
`bootstrap`, `documents`, `tasks`, `requirements` (review), `scenarios`
|
|
@@ -4,8 +4,12 @@ import type { Block } from '~/types/domain'
|
|
|
4
4
|
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
5
5
|
import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
|
|
6
6
|
|
|
7
|
-
// Documents (from any source) attached to a task
|
|
8
|
-
//
|
|
7
|
+
// Documents (from any source) attached to a task OR an initiative as agent
|
|
8
|
+
// context, shown inside the InspectorPanel. An initiative takes the same
|
|
9
|
+
// attachments (the create-initiative modal stages them exactly as the add-task one
|
|
10
|
+
// does) and its whole planning pipeline reads them, so it gets the same section —
|
|
11
|
+
// only the prose differs, which is why the hint/empty copy is level-keyed below.
|
|
12
|
+
// Attaching uses the SAME inline picker as task creation
|
|
9
13
|
// (source selector + repo→file browse + free-text search + paste-by-reference —
|
|
10
14
|
// ContextDocumentPicker), NOT the old dropdown that opened a second, page-level
|
|
11
15
|
// "Import a page…" modal on top of the inspector. Stacked page-level modals don't
|
|
@@ -27,6 +31,18 @@ onMounted(() => {
|
|
|
27
31
|
})
|
|
28
32
|
|
|
29
33
|
const linked = computed(() => documents.docsForBlock(props.block.id))
|
|
34
|
+
|
|
35
|
+
// Two STATIC literal keys per string, picked by level — the copy names what reads the
|
|
36
|
+
// document (the agents implementing a task vs the pipeline that plans an initiative), which
|
|
37
|
+
// is the whole point of the hint. Assembling one key from `block.level` would defeat the
|
|
38
|
+
// typed message-key check for a two-member choice that gains nothing from being dynamic.
|
|
39
|
+
const isInitiative = computed(() => props.block.level === 'initiative')
|
|
40
|
+
const hint = computed(() =>
|
|
41
|
+
isInitiative.value ? t('documents.taskDocs.hintInitiative') : t('documents.taskDocs.hint'),
|
|
42
|
+
)
|
|
43
|
+
const emptyHint = computed(() =>
|
|
44
|
+
isInitiative.value ? t('documents.taskDocs.emptyInitiative') : t('documents.taskDocs.empty'),
|
|
45
|
+
)
|
|
30
46
|
// Already-linked docs, so the inline picker filters them out / never re-offers them.
|
|
31
47
|
const chosenKeys = computed(() =>
|
|
32
48
|
linked.value.map((d) =>
|
|
@@ -71,7 +87,7 @@ async function attach(item: PendingContext) {
|
|
|
71
87
|
<InspectorSection
|
|
72
88
|
v-if="documents.available"
|
|
73
89
|
:title="t('documents.taskDocs.heading')"
|
|
74
|
-
:hint="
|
|
90
|
+
:hint="hint"
|
|
75
91
|
:count="linked.length"
|
|
76
92
|
>
|
|
77
93
|
<template #actions>
|
|
@@ -130,7 +146,7 @@ async function attach(item: PendingContext) {
|
|
|
130
146
|
</a>
|
|
131
147
|
</div>
|
|
132
148
|
<p v-else class="text-[11px] text-slate-500">
|
|
133
|
-
{{
|
|
149
|
+
{{ emptyHint }}
|
|
134
150
|
</p>
|
|
135
151
|
</InspectorSection>
|
|
136
152
|
</template>
|
|
@@ -10,40 +10,66 @@
|
|
|
10
10
|
// (`ui.openInitiativePlanning`) or as the interviewer step's result view. Live `initiative`
|
|
11
11
|
// stream events patch the store, so an open window follows the interview as it progresses.
|
|
12
12
|
//
|
|
13
|
+
// An interview runs over MULTIPLE ROUNDS and the entity keeps the settled ones, so the list is a
|
|
14
|
+
// mix of what the human still owes an answer and what they already dealt with. It renders pending
|
|
15
|
+
// first (`orderInterviewQuestions`) — see the `order` snapshot below for why that is recomputed per
|
|
16
|
+
// round rather than live.
|
|
17
|
+
//
|
|
13
18
|
// CONTINUE/PROCEED ARE ASYNC. They only record the intent on the parked step and wake the durable
|
|
14
19
|
// driver; the interviewer LLM then runs for as long as it takes, and the response carries the
|
|
15
20
|
// PRE-resume entity. So the window must not key its body on the entity alone — that renders
|
|
16
21
|
// identically before and after the click, which reads as the button having done nothing. The
|
|
17
22
|
// phase below folds the planning RUN's status in, so the wait is visible and a failed pass says
|
|
18
23
|
// so instead of leaving the human staring at questions they already submitted.
|
|
19
|
-
import { computed, reactive, watch } from 'vue'
|
|
24
|
+
import { computed, reactive, ref, watch } from 'vue'
|
|
20
25
|
import ClarificationItem from '~/components/common/ClarificationItem.vue'
|
|
21
26
|
import InterviewGateNotice from '~/components/common/InterviewGateNotice.vue'
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
INITIATIVE_STATUS_LABEL_KEYS,
|
|
29
|
+
isPendingQuestion,
|
|
30
|
+
orderInterviewQuestions,
|
|
31
|
+
} from '~/utils/initiative'
|
|
23
32
|
import { interviewGatePhase } from '~/utils/interviewGate'
|
|
24
33
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
34
|
+
import StepRunMeta from '~/components/panels/StepRunMeta.vue'
|
|
25
35
|
|
|
26
36
|
const board = useBoardStore()
|
|
27
37
|
const initiatives = useInitiativesStore()
|
|
28
|
-
const execution = useExecutionStore()
|
|
29
38
|
const { t } = useI18n()
|
|
30
39
|
|
|
31
|
-
const { open, blockId, close } = useResultView('initiative-planning', {
|
|
40
|
+
const { open, blockId, instanceId, stepIndex, close } = useResultView('initiative-planning', {
|
|
32
41
|
onOpen: ({ blockId }) => void initiatives.load(blockId),
|
|
33
42
|
})
|
|
34
43
|
|
|
35
44
|
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
36
45
|
const initiative = computed(() => (blockId.value ? initiatives.forBlock(blockId.value) : null))
|
|
37
|
-
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The planning run + the interviewer step's run details, resolved through the shared seam so this
|
|
49
|
+
* window reports the same "which run is this / how did the model do" facts as every other agent
|
|
50
|
+
* window — including on the card/inspector entry point, which carries no step (see
|
|
51
|
+
* `useResultViewRunMeta`). `run` is the same instance the phase below reads.
|
|
52
|
+
*/
|
|
53
|
+
const {
|
|
54
|
+
instance: run,
|
|
55
|
+
step: metaStep,
|
|
56
|
+
instanceId: runId,
|
|
57
|
+
position,
|
|
58
|
+
totalSteps,
|
|
59
|
+
runFailed,
|
|
60
|
+
failureAt,
|
|
61
|
+
} = useResultViewRunMeta('initiative-planning', {
|
|
62
|
+
blockId: () => blockId.value,
|
|
63
|
+
instanceId: () => instanceId.value,
|
|
64
|
+
stepIndex: () => stepIndex.value,
|
|
65
|
+
})
|
|
38
66
|
|
|
39
67
|
/** Every interview exchange, with a stable key for the list + draft map. */
|
|
40
68
|
const questions = computed(() =>
|
|
41
69
|
(initiative.value?.qa ?? []).map((q, i) => ({ ...q, key: q.id ?? `q-${i}` })),
|
|
42
70
|
)
|
|
43
71
|
/** Questions still needing an answer: not dismissed, and not yet answered (mirrors backend). */
|
|
44
|
-
const pending = computed(() =>
|
|
45
|
-
questions.value.filter((q) => q.status !== 'dismissed' && !(q.answer ?? '').trim()),
|
|
46
|
-
)
|
|
72
|
+
const pending = computed(() => questions.value.filter(isPendingQuestion))
|
|
47
73
|
|
|
48
74
|
// Per-question answer drafts, seeded from the entity and refreshed as new rounds arrive
|
|
49
75
|
// without clobbering an answer the human is mid-edit on.
|
|
@@ -58,6 +84,28 @@ watch(
|
|
|
58
84
|
{ immediate: true },
|
|
59
85
|
)
|
|
60
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Render order (pending first — see `orderInterviewQuestions`), re-snapshotted ONLY when the
|
|
89
|
+
* question SET changes, i.e. when a round lands. Deriving it live from the answers instead would
|
|
90
|
+
* yank a question out from under the human the moment they blurred its textarea and shuffle
|
|
91
|
+
* everything below it up, while they are reading down the list. Re-snapshotting per ROUND rather
|
|
92
|
+
* than per question is what keeps a window left open across rounds correct: a question answered in
|
|
93
|
+
* round one has to sink below round two's new ones, which a rank frozen at first sight never would.
|
|
94
|
+
*/
|
|
95
|
+
const order = ref<string[]>([])
|
|
96
|
+
watch(
|
|
97
|
+
() => questions.value.map((q) => q.key).join('|'),
|
|
98
|
+
() => {
|
|
99
|
+
order.value = orderInterviewQuestions(questions.value).map((q) => q.key)
|
|
100
|
+
},
|
|
101
|
+
{ immediate: true },
|
|
102
|
+
)
|
|
103
|
+
const orderedQuestions = computed(() => {
|
|
104
|
+
const rank = new Map(order.value.map((key, i) => [key, i]))
|
|
105
|
+
// A question the snapshot has not seen is by definition new, so it is pending and sorts first.
|
|
106
|
+
return [...questions.value].sort((a, b) => (rank.get(a.key) ?? -1) - (rank.get(b.key) ?? -1))
|
|
107
|
+
})
|
|
108
|
+
|
|
61
109
|
const resuming = computed(() => initiatives.resuming)
|
|
62
110
|
|
|
63
111
|
/**
|
|
@@ -155,7 +203,7 @@ async function onDiscard() {
|
|
|
155
203
|
icon-class="bg-indigo-500/15 text-indigo-300"
|
|
156
204
|
:title="initiative?.title ?? block?.title ?? t('initiative.planning.title')"
|
|
157
205
|
:subtitle="t('initiative.planning.subtitle')"
|
|
158
|
-
width="
|
|
206
|
+
width="4xl"
|
|
159
207
|
testid="initiative-planning-window"
|
|
160
208
|
@close="close"
|
|
161
209
|
>
|
|
@@ -165,79 +213,104 @@ async function onDiscard() {
|
|
|
165
213
|
</UBadge>
|
|
166
214
|
</template>
|
|
167
215
|
|
|
168
|
-
<div class="min-h-0 flex-1
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
216
|
+
<div class="flex min-h-0 flex-1">
|
|
217
|
+
<div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
|
|
218
|
+
<!-- No entity yet -->
|
|
219
|
+
<div
|
|
220
|
+
v-if="!initiative"
|
|
221
|
+
class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
|
|
222
|
+
>
|
|
223
|
+
<UIcon name="i-lucide-messages-square" class="h-8 w-8 opacity-40" />
|
|
224
|
+
<p class="text-sm">{{ t('initiative.planning.empty') }}</p>
|
|
225
|
+
</div>
|
|
177
226
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
227
|
+
<template v-else>
|
|
228
|
+
<p class="mb-4 text-[13px] leading-relaxed text-slate-300">
|
|
229
|
+
{{ t('initiative.planning.intro') }}
|
|
230
|
+
</p>
|
|
182
231
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
232
|
+
<!-- A pass is running: the human is waiting on the planner. Without this the window is
|
|
233
|
+
byte-identical to the parked state and the submit reads as a no-op. -->
|
|
234
|
+
<InterviewGateNotice
|
|
235
|
+
v-if="phase === 'working'"
|
|
236
|
+
variant="working"
|
|
237
|
+
:title="t('initiative.planning.working')"
|
|
238
|
+
:hint="t('initiative.planning.workingHint')"
|
|
239
|
+
testid="initiative-planning-working"
|
|
240
|
+
/>
|
|
192
241
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
242
|
+
<!-- The planning run stopped before the interview settled — a dead end otherwise. -->
|
|
243
|
+
<InterviewGateNotice
|
|
244
|
+
v-else-if="phase === 'failed'"
|
|
245
|
+
variant="failed"
|
|
246
|
+
:title="t('initiative.planning.failed')"
|
|
247
|
+
:hint="t('initiative.planning.failedHint')"
|
|
248
|
+
testid="initiative-planning-failed"
|
|
249
|
+
/>
|
|
201
250
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
251
|
+
<!-- Planning was never started, so there is nothing to answer YET (distinct from
|
|
252
|
+
converged, which means the planner already has what it needs). -->
|
|
253
|
+
<div
|
|
254
|
+
v-else-if="phase === 'idle' && questions.length === 0"
|
|
255
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
256
|
+
data-testid="initiative-planning-idle"
|
|
257
|
+
>
|
|
258
|
+
{{ t('initiative.planning.idle') }}
|
|
259
|
+
</div>
|
|
211
260
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
261
|
+
<!-- Converged / no pending questions -->
|
|
262
|
+
<div
|
|
263
|
+
v-else-if="phase === 'converged' || questions.length === 0"
|
|
264
|
+
class="rounded-lg border border-slate-800 bg-slate-950/40 p-4 text-center text-[13px] text-slate-400"
|
|
265
|
+
data-testid="initiative-planning-converged"
|
|
266
|
+
>
|
|
267
|
+
{{ t('initiative.planning.converged') }}
|
|
268
|
+
</div>
|
|
220
269
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
270
|
+
<!-- Interview questions — the shared clarification surface (answer / not-relevant /
|
|
271
|
+
recommend), reused with the requirements-review window. -->
|
|
272
|
+
<ul v-else class="space-y-4">
|
|
273
|
+
<li
|
|
274
|
+
v-for="q in orderedQuestions"
|
|
275
|
+
:key="q.key"
|
|
276
|
+
data-testid="initiative-planning-question"
|
|
277
|
+
>
|
|
278
|
+
<ClarificationItem
|
|
279
|
+
v-model:answer="drafts[q.key]"
|
|
280
|
+
:prompt="q.question"
|
|
281
|
+
:dismissed="q.status === 'dismissed'"
|
|
282
|
+
:recommendation="q.recommendation"
|
|
283
|
+
:recommending="!!q.id && initiatives.recommending.has(q.id)"
|
|
284
|
+
:answer-placeholder="t('initiative.planning.answerPlaceholder')"
|
|
285
|
+
@persist="persist(q)"
|
|
286
|
+
@dismiss="setStatus(q, 'dismissed')"
|
|
287
|
+
@reopen="setStatus(q, 'open')"
|
|
288
|
+
@recommend="recommend(q)"
|
|
289
|
+
@use-recommendation="useRecommendation(q)"
|
|
290
|
+
/>
|
|
291
|
+
</li>
|
|
292
|
+
</ul>
|
|
293
|
+
</template>
|
|
294
|
+
</div>
|
|
295
|
+
|
|
296
|
+
<!-- Run details: the shared run-metadata + LLM model-activity block every agent window
|
|
297
|
+
carries (step position, live duration, model, run id, calls + token usage). Resolved
|
|
298
|
+
through `useResultViewRunMeta`, so it is present on the card / inspector entry point
|
|
299
|
+
too — where this window carries no step index of its own. -->
|
|
300
|
+
<aside
|
|
301
|
+
v-if="metaStep"
|
|
302
|
+
data-testid="initiative-planning-run-meta"
|
|
303
|
+
class="hidden w-60 shrink-0 flex-col gap-4 overflow-y-auto border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
|
|
304
|
+
>
|
|
305
|
+
<StepRunMeta
|
|
306
|
+
:step="metaStep"
|
|
307
|
+
:instance-id="runId"
|
|
308
|
+
:step-number="position"
|
|
309
|
+
:total-steps="totalSteps"
|
|
310
|
+
:run-failed="runFailed"
|
|
311
|
+
:failure-at="failureAt"
|
|
312
|
+
/>
|
|
313
|
+
</aside>
|
|
241
314
|
</div>
|
|
242
315
|
|
|
243
316
|
<!-- Action rail. The submit/plan-now pair shows only while the run is actually parked on the
|