@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
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
// unavailable or failed still shows its candidates (flagged as unassessed, since the scan is
|
|
11
11
|
// useful on its own), and a scan that hit its cap says so — a silently shortened list reads
|
|
12
12
|
// exactly like an exhaustive one.
|
|
13
|
+
//
|
|
14
|
+
// The tracker selector doubles as the "add a tracker" affordance (the same two-tier menu
|
|
15
|
+
// `<ContextIssuePicker>` renders, off the shared `buildSourceChoices`): a hunt is a common
|
|
16
|
+
// place to find out the tracker holding the bugs isn't connected here yet, and the answer
|
|
17
|
+
// has to be a route to that tracker's own connect screen rather than "go find the
|
|
18
|
+
// Integrations hub". The connect modal opens OVER the hunt, so nothing typed here is lost.
|
|
19
|
+
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
13
20
|
import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
14
21
|
import type {
|
|
15
22
|
BugHuntAnalysisStatus,
|
|
@@ -17,6 +24,7 @@ import type {
|
|
|
17
24
|
BugHuntConfidence,
|
|
18
25
|
TaskSourceKind,
|
|
19
26
|
} from '~/types/domain'
|
|
27
|
+
import { type SourceChoice, buildSourceChoices, reconcileSource } from '~/utils/taskSources'
|
|
20
28
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
21
29
|
|
|
22
30
|
const { t, d, n } = useI18n()
|
|
@@ -54,8 +62,64 @@ const containerItems = computed(() =>
|
|
|
54
62
|
})),
|
|
55
63
|
)
|
|
56
64
|
|
|
57
|
-
const
|
|
58
|
-
|
|
65
|
+
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
66
|
+
|
|
67
|
+
// Two-tier tracker menu: pick one the workspace already offers, or add one it doesn't.
|
|
68
|
+
const sourceChoices = computed(() => buildSourceChoices(tasks.sources, source.value))
|
|
69
|
+
const sourceMenu = computed<DropdownMenuItem[][]>(() =>
|
|
70
|
+
sourceChoices.value.map((group) =>
|
|
71
|
+
group.map((choice) =>
|
|
72
|
+
choice.action === 'select'
|
|
73
|
+
? {
|
|
74
|
+
label: choice.label,
|
|
75
|
+
icon: choice.icon,
|
|
76
|
+
trailingIcon: choice.active ? 'i-lucide-check' : undefined,
|
|
77
|
+
onSelect: () => {
|
|
78
|
+
source.value = choice.source
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
: {
|
|
82
|
+
label: addLabel(choice),
|
|
83
|
+
icon: 'i-lucide-plug',
|
|
84
|
+
onSelect: () => addSource(choice.source),
|
|
85
|
+
},
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
/** The trackers that can be added — the empty state's buttons, where none is offered yet. */
|
|
91
|
+
const addableSources = computed(() =>
|
|
92
|
+
sourceChoices.value.flat().filter((c) => c.action !== 'select'),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wording for an addable tracker: `enable` is connected but toggled off for this workspace,
|
|
97
|
+
* so the user is never told to "connect" something they already connected.
|
|
98
|
+
*/
|
|
99
|
+
function addLabel(choice: SourceChoice): string {
|
|
100
|
+
return choice.action === 'enable'
|
|
101
|
+
? t('bugHunt.enableSource', { label: choice.label })
|
|
102
|
+
: t('bugHunt.connectSource', { label: choice.label })
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The tracker the user left to add, so it becomes the selection the moment it turns up
|
|
107
|
+
* offered (the connect modal re-probes on success and this hunt stays open underneath it).
|
|
108
|
+
* Also the reconcile trigger for a source that STOPS being offered — disconnected, or
|
|
109
|
+
* toggled off in settings while the hunt sat open.
|
|
110
|
+
*/
|
|
111
|
+
const awaitingConnect = ref<TaskSourceKind | null>(null)
|
|
112
|
+
function addSource(s: TaskSourceKind) {
|
|
113
|
+
awaitingConnect.value = s
|
|
114
|
+
ui.openTaskConnect(s)
|
|
115
|
+
}
|
|
116
|
+
watch(
|
|
117
|
+
() => tasks.offeredSources.map((s) => s.source),
|
|
118
|
+
(offered) => {
|
|
119
|
+
const next = reconcileSource(offered, source.value, awaitingConnect.value)
|
|
120
|
+
if (next && next === awaitingConnect.value) awaitingConnect.value = null
|
|
121
|
+
if (next !== source.value) source.value = next
|
|
122
|
+
},
|
|
59
123
|
)
|
|
60
124
|
|
|
61
125
|
const boardItems = computed(() =>
|
|
@@ -100,6 +164,7 @@ watch(open, (isOpen) => {
|
|
|
100
164
|
boardId.value = ''
|
|
101
165
|
issueType.value = ''
|
|
102
166
|
labels.value = ''
|
|
167
|
+
awaitingConnect.value = null
|
|
103
168
|
source.value = ui.bugHunt?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
104
169
|
containerId.value = ui.bugHunt?.containerId ?? containerItems.value[0]?.value
|
|
105
170
|
if (source.value) hunt.loadBoards(source.value)
|
|
@@ -193,16 +258,16 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
193
258
|
<div v-if="!tasks.anyOffered" class="space-y-3 text-center">
|
|
194
259
|
<UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
|
|
195
260
|
<p class="text-sm text-slate-400">{{ t('bugHunt.connectFirst') }}</p>
|
|
196
|
-
<div class="flex justify-center gap-2">
|
|
261
|
+
<div class="flex flex-wrap justify-center gap-2">
|
|
197
262
|
<UButton
|
|
198
|
-
v-for="
|
|
199
|
-
:key="
|
|
263
|
+
v-for="choice in addableSources"
|
|
264
|
+
:key="choice.source"
|
|
200
265
|
color="primary"
|
|
201
266
|
variant="soft"
|
|
202
|
-
:icon="
|
|
203
|
-
@click="
|
|
267
|
+
:icon="choice.icon"
|
|
268
|
+
@click="addSource(choice.source)"
|
|
204
269
|
>
|
|
205
|
-
{{
|
|
270
|
+
{{ addLabel(choice) }}
|
|
206
271
|
</UButton>
|
|
207
272
|
</div>
|
|
208
273
|
</div>
|
|
@@ -217,7 +282,24 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
|
|
|
217
282
|
|
|
218
283
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
219
284
|
<UFormField :label="t('bugHunt.tracker')">
|
|
220
|
-
|
|
285
|
+
<!-- The selector is also the way to ADD a tracker: each entry in the second group
|
|
286
|
+
opens that tracker's own connect screen over this modal, so the hunt (and
|
|
287
|
+
anything typed into it) is still here when the user comes back. -->
|
|
288
|
+
<UDropdownMenu
|
|
289
|
+
:items="sourceMenu"
|
|
290
|
+
:content="{ side: 'bottom', align: 'start' }"
|
|
291
|
+
class="w-full"
|
|
292
|
+
>
|
|
293
|
+
<UButton
|
|
294
|
+
color="neutral"
|
|
295
|
+
variant="soft"
|
|
296
|
+
:icon="descriptor?.icon"
|
|
297
|
+
trailing-icon="i-lucide-chevron-down"
|
|
298
|
+
class="w-full justify-between"
|
|
299
|
+
>
|
|
300
|
+
<span class="truncate">{{ descriptor?.label ?? t('bugHunt.pickTracker') }}</span>
|
|
301
|
+
</UButton>
|
|
302
|
+
</UDropdownMenu>
|
|
221
303
|
</UFormField>
|
|
222
304
|
|
|
223
305
|
<UFormField :label="t('bugHunt.board')">
|
|
@@ -21,7 +21,7 @@ import type { TaskSourceReadReason } from '@cat-factory/contracts'
|
|
|
21
21
|
import type { SourceTask, TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
22
22
|
import { apiErrorReason } from '~/composables/api/errors'
|
|
23
23
|
import EmptyState from '~/components/common/EmptyState.vue'
|
|
24
|
-
import { buildSourceChoices, reconcileSource } from '~/
|
|
24
|
+
import { buildSourceChoices, reconcileSource } from '~/utils/taskSources'
|
|
25
25
|
|
|
26
26
|
const props = defineProps<{
|
|
27
27
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Inspector section for a task block: the tracker issues (Jira,
|
|
3
|
-
// attached to it as agent context.
|
|
2
|
+
// Inspector section for a task OR initiative block: the tracker issues (Jira,
|
|
3
|
+
// GitHub Issues, …) attached to it as agent context. An initiative takes the same
|
|
4
|
+
// attachments (the create-initiative modal stages them exactly as the add-task one
|
|
5
|
+
// does) and its whole planning pipeline reads them, so it gets the same section —
|
|
6
|
+
// only the prose differs, which is why the hint/empty copy is level-keyed below.
|
|
7
|
+
// Attaching uses the SAME inline picker as task
|
|
4
8
|
// creation (source selector + in-repo search + paste-by-reference —
|
|
5
9
|
// ContextIssuePicker), NOT the old dropdown that opened a second, page-level
|
|
6
10
|
// "Import an issue…" modal on top of the inspector (stacked page-level modals
|
|
@@ -28,6 +32,20 @@ onMounted(() => {
|
|
|
28
32
|
})
|
|
29
33
|
|
|
30
34
|
const linked = computed(() => tasks.tasksForBlock(props.block.id))
|
|
35
|
+
|
|
36
|
+
// Two STATIC literal keys per string, picked by level — the copy names what reads the issue
|
|
37
|
+
// (the agents implementing a task vs the pipeline that plans an initiative), which is the
|
|
38
|
+
// whole point of the hint. Assembling one key from `block.level` would defeat the typed
|
|
39
|
+
// message-key check for a two-member choice that gains nothing from being dynamic.
|
|
40
|
+
const isInitiative = computed(() => props.block.level === 'initiative')
|
|
41
|
+
const hint = computed(() =>
|
|
42
|
+
isInitiative.value ? t('tasks.contextIssues.hintInitiative') : t('tasks.contextIssues.hint'),
|
|
43
|
+
)
|
|
44
|
+
const emptyHint = computed(() =>
|
|
45
|
+
isInitiative.value
|
|
46
|
+
? t('tasks.contextIssues.emptyHintInitiative')
|
|
47
|
+
: t('tasks.contextIssues.emptyHint'),
|
|
48
|
+
)
|
|
31
49
|
// Already-linked issues, so the inline picker filters them out / never re-offers them.
|
|
32
50
|
const chosenKeys = computed(() =>
|
|
33
51
|
linked.value.map((issue) =>
|
|
@@ -71,7 +89,7 @@ async function attach(item: PendingContext) {
|
|
|
71
89
|
<InspectorSection
|
|
72
90
|
v-if="tasks.available"
|
|
73
91
|
:title="t('tasks.contextIssues.title')"
|
|
74
|
-
:hint="
|
|
92
|
+
:hint="hint"
|
|
75
93
|
:count="linked.length"
|
|
76
94
|
>
|
|
77
95
|
<template #actions>
|
|
@@ -133,7 +151,7 @@ async function attach(item: PendingContext) {
|
|
|
133
151
|
</a>
|
|
134
152
|
</div>
|
|
135
153
|
<p v-else class="text-[11px] text-slate-500">
|
|
136
|
-
{{
|
|
154
|
+
{{ emptyHint }}
|
|
137
155
|
</p>
|
|
138
156
|
</InspectorSection>
|
|
139
157
|
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { PipelineStep } from '~/types/execution'
|
|
3
|
+
import { resultViewStepIndex } from '~/composables/useResultViewRunMeta'
|
|
4
|
+
|
|
5
|
+
// The off-path fallback: which step a result window reports run metadata for when it was opened
|
|
6
|
+
// from a board card / the inspector rather than from the run's timeline. The precedence exists
|
|
7
|
+
// because a window's kind set can mix model-running steps with bookkeeping ones — the initiative
|
|
8
|
+
// tracker is registered for the analyst, the planner AND `initiative-committer`, which runs no
|
|
9
|
+
// model — so "the most recent one" alone would anchor on the step with no telemetry.
|
|
10
|
+
|
|
11
|
+
function step(agentKind: string, overrides: Partial<PipelineStep> = {}): PipelineStep {
|
|
12
|
+
return { agentKind, state: 'pending', ...overrides } as PipelineStep
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** A step that recorded model calls (the shape `attachStepMetrics` folds onto the run). */
|
|
16
|
+
function metered(agentKind: string, calls: number, startedAt = 1_000): PipelineStep {
|
|
17
|
+
return step(agentKind, { startedAt, metrics: { calls } as PipelineStep['metrics'] })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('resultViewStepIndex', () => {
|
|
21
|
+
it('is null when no step declares the view', () => {
|
|
22
|
+
const steps = [step('coder'), step('tester')]
|
|
23
|
+
expect(resultViewStepIndex(steps, 'initiative-tracker')).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('falls back to the first declaring step before the run reaches any of them', () => {
|
|
27
|
+
const steps = [
|
|
28
|
+
step('initiative-interviewer'),
|
|
29
|
+
step('initiative-analyst'),
|
|
30
|
+
step('initiative-planner'),
|
|
31
|
+
]
|
|
32
|
+
// Index 1 — the analyst; the interviewer declares the PLANNING window, not the tracker.
|
|
33
|
+
expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('prefers the last started step while the run is mid-flight with no telemetry yet', () => {
|
|
37
|
+
const steps = [
|
|
38
|
+
step('initiative-interviewer', { startedAt: 10 }),
|
|
39
|
+
step('initiative-analyst', { startedAt: 20 }),
|
|
40
|
+
step('initiative-planner'),
|
|
41
|
+
]
|
|
42
|
+
expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('prefers the last step that actually ran a model over a later bookkeeping step', () => {
|
|
46
|
+
const steps = [
|
|
47
|
+
metered('initiative-interviewer', 4),
|
|
48
|
+
metered('initiative-analyst', 12),
|
|
49
|
+
metered('initiative-planner', 31),
|
|
50
|
+
// Runs no model, so it records no calls — and must not shadow the planner's telemetry.
|
|
51
|
+
step('initiative-committer', { startedAt: 2_000 }),
|
|
52
|
+
]
|
|
53
|
+
expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(2)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('ignores a metered step belonging to another window', () => {
|
|
57
|
+
const steps = [metered('initiative-interviewer', 9), step('initiative-analyst')]
|
|
58
|
+
expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(1)
|
|
59
|
+
expect(resultViewStepIndex(steps, 'initiative-planning')).toBe(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('treats a zero-call rollup as unmetered', () => {
|
|
63
|
+
const steps = [
|
|
64
|
+
metered('initiative-analyst', 5),
|
|
65
|
+
step('initiative-planner', {
|
|
66
|
+
startedAt: 3_000,
|
|
67
|
+
metrics: { calls: 0 } as PipelineStep['metrics'],
|
|
68
|
+
}),
|
|
69
|
+
]
|
|
70
|
+
// The analyst keeps the tier-1 match: a rollup that recorded nothing is not telemetry, so
|
|
71
|
+
// the later step doesn't shadow it just for having a `metrics` object.
|
|
72
|
+
expect(resultViewStepIndex(steps, 'initiative-tracker')).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import type { ExecutionInstance, PipelineStep } from '~/types/execution'
|
|
3
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves the `StepRunMeta` prop bundle for a dedicated result window, for BOTH of the ways
|
|
7
|
+
* such a window opens (see `stores/ui/resultViews.ts`):
|
|
8
|
+
*
|
|
9
|
+
* - the PIPELINE path (`dispatchStepView`), which carries `instanceId` + `stepIndex` — the step
|
|
10
|
+
* is exactly the one that was clicked, as every step-backed window already assumes; and
|
|
11
|
+
* - an OFF-PATH open (`ui.openInitiativeTracker`, `ui.openInitiativePlanning`, …), which carries
|
|
12
|
+
* only a block id, because the human entered from the board card or the inspector rather than
|
|
13
|
+
* from the run's timeline.
|
|
14
|
+
*
|
|
15
|
+
* Windows that are reachable BOTH ways used to fall back to no metadata at all on the second
|
|
16
|
+
* route, which is how the initiative windows ended up with no run details, no model and no token
|
|
17
|
+
* telemetry — on the entry point people actually use. The fallback resolves the block's live run
|
|
18
|
+
* and picks the step this window represents, so the two routes report the same facts.
|
|
19
|
+
*/
|
|
20
|
+
export function useResultViewRunMeta(
|
|
21
|
+
viewId: string,
|
|
22
|
+
source: {
|
|
23
|
+
blockId: () => string | null
|
|
24
|
+
instanceId: () => string | null
|
|
25
|
+
stepIndex: () => number | null
|
|
26
|
+
},
|
|
27
|
+
) {
|
|
28
|
+
const execution = useExecutionStore()
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The run the metadata describes: the dispatched one on the pipeline path, else the block's
|
|
32
|
+
* live run. Read reactively rather than resolved once at open time, so a window left open
|
|
33
|
+
* across a run start/advance follows it instead of freezing on whatever existed at mount.
|
|
34
|
+
*/
|
|
35
|
+
const instance = computed<ExecutionInstance | null>(() => {
|
|
36
|
+
const id = source.instanceId()
|
|
37
|
+
if (id !== null) return execution.getInstance(id) ?? null
|
|
38
|
+
const blockId = source.blockId()
|
|
39
|
+
return blockId ? (execution.getByBlock(blockId) ?? null) : null
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const stepNumber = computed<number | null>(() => {
|
|
43
|
+
const index = source.stepIndex()
|
|
44
|
+
if (index !== null) return index
|
|
45
|
+
return instance.value ? resultViewStepIndex(instance.value.steps, viewId) : null
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const step = computed<PipelineStep | null>(() => {
|
|
49
|
+
const index = stepNumber.value
|
|
50
|
+
if (index === null) return null
|
|
51
|
+
return instance.value?.steps[index] ?? null
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
instance,
|
|
56
|
+
step,
|
|
57
|
+
/** The run id, for the copyable field + the "View all calls" observability link. */
|
|
58
|
+
instanceId: computed(() => instance.value?.id),
|
|
59
|
+
/** 1-based position, as `StepRunMeta` renders it ("N of M"). */
|
|
60
|
+
position: computed(() => (stepNumber.value === null ? undefined : stepNumber.value + 1)),
|
|
61
|
+
totalSteps: computed(() => instance.value?.steps.length),
|
|
62
|
+
runFailed: computed(() => instance.value?.status === 'failed'),
|
|
63
|
+
failureAt: computed(() => instance.value?.failure?.occurredAt),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The step an off-path open should report, among the run's steps whose agent kind declares
|
|
69
|
+
* `viewId` as its dedicated result view (the same `resultView` seam `dispatchStepView` routes
|
|
70
|
+
* on, so the mapping can never drift from the window registry).
|
|
71
|
+
*
|
|
72
|
+
* Precedence, most to least specific:
|
|
73
|
+
*
|
|
74
|
+
* 1. the last step that has actually RUN A MODEL. A window's kind set can span both model-running
|
|
75
|
+
* steps and bookkeeping ones — the initiative tracker is registered for the analyst and the
|
|
76
|
+
* planner but also for `initiative-committer`, which runs no model — and anchoring on the last
|
|
77
|
+
* STARTED step alone would hand a human who opened the window for its telemetry the one step
|
|
78
|
+
* guaranteed to have none.
|
|
79
|
+
* 2. the last started step, so a run mid-flight (or one whose telemetry sink isn't wired) still
|
|
80
|
+
* reports timing, model and run id rather than nothing.
|
|
81
|
+
* 3. the first declaring step, so a run that hasn't reached any of them yet still names which
|
|
82
|
+
* step is coming and how far into the pipeline it sits.
|
|
83
|
+
*
|
|
84
|
+
* Null when the run declares none — the caller hides the sidebar rather than rendering an empty one.
|
|
85
|
+
*/
|
|
86
|
+
export function resultViewStepIndex(steps: readonly PipelineStep[], viewId: string): number | null {
|
|
87
|
+
let first: number | null = null
|
|
88
|
+
let lastStarted: number | null = null
|
|
89
|
+
let lastMetered: number | null = null
|
|
90
|
+
for (let i = 0; i < steps.length; i++) {
|
|
91
|
+
const step = steps[i]
|
|
92
|
+
if (!step || agentKindMeta(step.agentKind).resultView !== viewId) continue
|
|
93
|
+
if (first === null) first = i
|
|
94
|
+
if (step.startedAt != null) lastStarted = i
|
|
95
|
+
if ((step.metrics?.calls ?? 0) > 0) lastMetered = i
|
|
96
|
+
}
|
|
97
|
+
return lastMetered ?? lastStarted ?? first
|
|
98
|
+
}
|
|
@@ -170,16 +170,17 @@ re-deriving the "which run is this / how did the model do" facts. **Composables*
|
|
|
170
170
|
**components** must be named through the `#components` virtual module (see the boxed note
|
|
171
171
|
below). Compose these:
|
|
172
172
|
|
|
173
|
-
| Building block
|
|
174
|
-
|
|
|
175
|
-
| `ResultWindowShell`
|
|
176
|
-
| `StepRunMeta`
|
|
177
|
-
| `MarkdownProse`
|
|
178
|
-
| `CopyButton`
|
|
179
|
-
| `InspectorSection`
|
|
180
|
-
| `useResultView(id)`
|
|
181
|
-
| `
|
|
182
|
-
| `
|
|
173
|
+
| Building block | Reference it as | What it gives you |
|
|
174
|
+
| ----------------------------- | ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
175
|
+
| `ResultWindowShell` | `#components` → `PanelsResultWindowShell` | The shared modal chrome for a result window — backdrop, header (icon/title/subtitle), a `#header-extras` slot, close button, and the modal _behaviour_ (focus-trap + return, body-scroll lock, shared-stack Escape via `useModalBehavior`). Pass `stepRef` to surface the shared "restart from here" control. |
|
|
176
|
+
| `StepRunMeta` | `#components` → `PanelsStepRunMeta` | **The shared run-details metadata block** every agent window reuses: step position, live duration, model, run id, and the LLM model-activity rollup. Drop it into your window's sidebar — never reinvent run metadata. |
|
|
177
|
+
| `MarkdownProse` | `#components` → `CommonMarkdownProse` | Render an agent's prose output as markdown. |
|
|
178
|
+
| `CopyButton` | `#components` → `CommonCopyButton` | The shared copy-to-clipboard affordance. |
|
|
179
|
+
| `InspectorSection` | `#components` → `PanelsInspectorSection` | The collapsible inspector-section shell (chevron header, count, hint) so a consumer panel reads like a built-in one. |
|
|
180
|
+
| `useResultView(id)` | auto-imported | The window seam contract: `{ open, blockId, instanceId, stepIndex, close }` (+ an `onOpen` loader for windows that fetch, and an `onClose` flush). Escape is owned by the shell, not here. |
|
|
181
|
+
| `useResultViewRunMeta(id, …)` | auto-imported | The `StepRunMeta` prop bundle (`{ step, instanceId, position, totalSteps, runFailed, failureAt }`), resolved for BOTH ways a window opens. A window reachable off-path — from a board card or the inspector — carries no `stepIndex`, so wiring `StepRunMeta` straight off `useResultView` leaves it blank on exactly that route; this resolves the block's live run and the step your view id declares instead. |
|
|
182
|
+
| `usePanelSubject<T>()` | `@modular-vue/core` | Read the block injected into an inspector panel by `<PanelsOutlet>`. |
|
|
183
|
+
| `useAppOverlays()` | auto-imported | Open / close your own top-level overlays: `{ open(id, subject?), close(), active }`. The store-free seam a nav `run` closure uses to open an `appOverlays`-slot component (see "Top-level overlays"). |
|
|
183
184
|
|
|
184
185
|
> **Reference layer components through `#components`, not bare tags.** Nuxt auto-registers a
|
|
185
186
|
> layer's components under a **path-derived** name (`components/panels/ResultWindowShell.vue`
|
|
@@ -98,11 +98,18 @@ describe('inspector panel group', () => {
|
|
|
98
98
|
expect(visibleIds(block('epic'))).toEqual(['epic-children'])
|
|
99
99
|
})
|
|
100
100
|
|
|
101
|
-
// An initiative
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
// An initiative shares two of the task body's panels: the CONTEXT sections (the
|
|
102
|
+
// create-initiative modal attaches the same documents/issues, and the planning pipeline reads
|
|
103
|
+
// them, so the inspector must surface them) and the execution panel (planning is an ordinary
|
|
104
|
+
// run — and that panel carries the only Stop / Discard-run controls that unwedge a stalled
|
|
105
|
+
// one). Order is pinned too: identity + controls, the context it was given, then the run.
|
|
106
|
+
it('an initiative shows its inspector, then the shared context + execution panels', () => {
|
|
107
|
+
expect(visibleIds(block('initiative'))).toEqual([
|
|
108
|
+
'initiative-inspector',
|
|
109
|
+
'task-context-docs',
|
|
110
|
+
'task-context-issues',
|
|
111
|
+
'task-execution',
|
|
112
|
+
])
|
|
106
113
|
})
|
|
107
114
|
|
|
108
115
|
it('no subject selected resolves to no panels', () => {
|
|
@@ -82,6 +82,14 @@ const isFrame = (b: Block) => b.level === 'frame'
|
|
|
82
82
|
* Before this it had no run surface at all, which is why a stuck plan was a dead end.
|
|
83
83
|
*/
|
|
84
84
|
const hasRuns = (b: Block) => isTask(b) || b.level === 'initiative'
|
|
85
|
+
/**
|
|
86
|
+
* A block that carries attached CONTEXT (imported documents + tracker issues). The
|
|
87
|
+
* create-initiative modal stages the same attachments the add-task modal does and links them
|
|
88
|
+
* to the initiative block, and the whole planning pipeline reads them — so an initiative
|
|
89
|
+
* whose inspector never showed them left the user with no evidence the attachment landed and
|
|
90
|
+
* no way to reach the source. Same panels, same store reads (both are keyed by block id only).
|
|
91
|
+
*/
|
|
92
|
+
const takesContext = (b: Block) => isTask(b) || b.level === 'initiative'
|
|
85
93
|
/** frame OR module — the "container" panels. */
|
|
86
94
|
const isContainer = (b: Block) => b.level === 'frame' || b.level === 'module'
|
|
87
95
|
/**
|
|
@@ -111,8 +119,9 @@ const isDeployableFrame = (b: Block) => isFrame(b) && b.type !== 'document'
|
|
|
111
119
|
* extensibility value.
|
|
112
120
|
*/
|
|
113
121
|
export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
114
|
-
|
|
115
|
-
{ id: 'task-context-
|
|
122
|
+
// Shared with the initiative body — an initiative takes the same attachments a task does.
|
|
123
|
+
{ id: 'task-context-docs', order: 10, when: takesContext },
|
|
124
|
+
{ id: 'task-context-issues', order: 20, when: takesContext },
|
|
116
125
|
{ id: 'recurring-schedule', order: 30, when: isTask },
|
|
117
126
|
// Shared with the initiative body — the panel renders a RUN, and an initiative has one.
|
|
118
127
|
{ id: 'task-execution', order: 40, when: hasRuns },
|
|
@@ -133,8 +142,8 @@ export const INSPECTOR_PANEL_SPECS: readonly InspectorPanelSpec[] = [
|
|
|
133
142
|
// test-infra and release-health panels above it.
|
|
134
143
|
{ id: 'service-validation-checks', order: 180, when: isDeployableFrame },
|
|
135
144
|
{ id: 'epic-children', order: 200, when: (b) => b.level === 'epic' },
|
|
136
|
-
// Ordered
|
|
137
|
-
//
|
|
145
|
+
// Ordered FIRST so an initiative reads the way a task does: its own identity + controls, then
|
|
146
|
+
// the context it was given (10/20), then the run detail (40). Levels never overlap, so this
|
|
138
147
|
// number only ever competes with the task ids the initiative body doesn't render.
|
|
139
|
-
{ id: 'initiative-inspector', order:
|
|
148
|
+
{ id: 'initiative-inspector', order: 5, when: (b) => b.level === 'initiative' },
|
|
140
149
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { INITIATIVE_ITEM_TERMINAL_STATUSES } from '@cat-factory/contracts'
|
|
2
2
|
import { describe, it, expect } from 'vitest'
|
|
3
|
-
import type { InitiativeItem, InitiativePhase } from '~/types/domain'
|
|
4
|
-
import { pendingCheckpointPhase } from './initiative'
|
|
3
|
+
import type { InitiativeItem, InitiativePhase, InitiativeQa } from '~/types/domain'
|
|
4
|
+
import { isPendingQuestion, orderInterviewQuestions, pendingCheckpointPhase } from './initiative'
|
|
5
5
|
|
|
6
6
|
// `pendingCheckpointPhase` mirrors the backend `pendingCheckpoint` (orchestration
|
|
7
7
|
// `initiative.logic.ts`); these pin the same ordering/edge cases the loop pauses on, so the
|
|
@@ -71,3 +71,82 @@ describe('pendingCheckpointPhase', () => {
|
|
|
71
71
|
expect(pendingCheckpointPhase(phases, [item('a', 'p1', status)])?.id).toBe('p1')
|
|
72
72
|
})
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
// `isPendingQuestion` mirrors the backend rule of the same name (orchestration
|
|
76
|
+
// `initiative.logic.ts`); `orderInterviewQuestions` is what the planning window renders by, so a
|
|
77
|
+
// multi-round interview puts what the human still owes an answer above what they already settled.
|
|
78
|
+
|
|
79
|
+
const qa = (over: Partial<InitiativeQa> & { id: string }): InitiativeQa => ({
|
|
80
|
+
question: over.id,
|
|
81
|
+
answer: '',
|
|
82
|
+
status: 'open',
|
|
83
|
+
...over,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('isPendingQuestion', () => {
|
|
87
|
+
it('is pending while unanswered and not dismissed', () => {
|
|
88
|
+
expect(isPendingQuestion(qa({ id: 'a' }))).toBe(true)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('is settled once answered', () => {
|
|
92
|
+
expect(isPendingQuestion(qa({ id: 'a', answer: 'yes' }))).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('treats a whitespace-only answer as unanswered', () => {
|
|
96
|
+
expect(isPendingQuestion(qa({ id: 'a', answer: ' \n' }))).toBe(true)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('is settled once dismissed, answered or not', () => {
|
|
100
|
+
expect(isPendingQuestion(qa({ id: 'a', status: 'dismissed' }))).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('treats an absent answer/status (a hand-authored exchange) as pending', () => {
|
|
104
|
+
expect(isPendingQuestion({})).toBe(true)
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
describe('orderInterviewQuestions', () => {
|
|
109
|
+
const ids = (list: InitiativeQa[]) => orderInterviewQuestions(list).map((q) => q.id)
|
|
110
|
+
|
|
111
|
+
it('floats a later round of unanswered questions above the settled digest', () => {
|
|
112
|
+
// The shape the backend's `[...retainedQa, ...pending]` append produces on round two.
|
|
113
|
+
const list = [
|
|
114
|
+
qa({ id: 'r1-answered', answer: 'yes' }),
|
|
115
|
+
qa({ id: 'r1-dismissed', status: 'dismissed' }),
|
|
116
|
+
qa({ id: 'r2-a' }),
|
|
117
|
+
qa({ id: 'r2-b' }),
|
|
118
|
+
]
|
|
119
|
+
expect(ids(list)).toEqual(['r2-a', 'r2-b', 'r1-answered', 'r1-dismissed'])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('keeps chronological order within each group', () => {
|
|
123
|
+
const list = [
|
|
124
|
+
qa({ id: 'p1' }),
|
|
125
|
+
qa({ id: 's1', answer: 'yes' }),
|
|
126
|
+
qa({ id: 'p2' }),
|
|
127
|
+
qa({ id: 's2', status: 'dismissed' }),
|
|
128
|
+
qa({ id: 'p3' }),
|
|
129
|
+
]
|
|
130
|
+
expect(ids(list)).toEqual(['p1', 'p2', 'p3', 's1', 's2'])
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('leaves a first round (all pending) exactly as the interviewer asked it', () => {
|
|
134
|
+
const list = [qa({ id: 'a' }), qa({ id: 'b' }), qa({ id: 'c' })]
|
|
135
|
+
expect(ids(list)).toEqual(['a', 'b', 'c'])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('leaves a fully settled interview in its digest order', () => {
|
|
139
|
+
const list = [qa({ id: 'a', answer: 'x' }), qa({ id: 'b', status: 'dismissed' })]
|
|
140
|
+
expect(ids(list)).toEqual(['a', 'b'])
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('does not mutate the stored order (the interviewer prompt + tracker digest read it)', () => {
|
|
144
|
+
const list = [qa({ id: 'answered', answer: 'x' }), qa({ id: 'pending' })]
|
|
145
|
+
orderInterviewQuestions(list)
|
|
146
|
+
expect(list.map((q) => q.id)).toEqual(['answered', 'pending'])
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('handles an empty interview', () => {
|
|
150
|
+
expect(orderInterviewQuestions([])).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
})
|
package/app/utils/initiative.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type {
|
|
|
6
6
|
InitiativePhase,
|
|
7
7
|
InitiativePresetDescriptor,
|
|
8
8
|
InitiativePresetInputs,
|
|
9
|
+
InitiativeQa,
|
|
9
10
|
InitiativeStatus,
|
|
10
11
|
} from '~/types/domain'
|
|
11
12
|
|
|
@@ -111,6 +112,37 @@ export function initiativeProgress(
|
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Whether a planning-interview question still needs a human answer: not dismissed, and no answer
|
|
117
|
+
* yet. Mirrors the backend `isPendingQuestion` (orchestration `initiative.logic.ts`) — the rule the
|
|
118
|
+
* interviewer, the retained-across-rounds digest and the continue gate all key off — so the window's
|
|
119
|
+
* pending list, its unanswered counter and its render order can never disagree with the engine
|
|
120
|
+
* about what is still open.
|
|
121
|
+
*/
|
|
122
|
+
export function isPendingQuestion(q: Partial<Pick<InitiativeQa, 'answer' | 'status'>>): boolean {
|
|
123
|
+
return q.status !== 'dismissed' && (q.answer ?? '').trim().length === 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Interview questions in the order the planning window renders them: everything still pending
|
|
128
|
+
* first, everything already settled (answered, or dismissed as not relevant) after, each group
|
|
129
|
+
* keeping the interviewer's own chronological order.
|
|
130
|
+
*
|
|
131
|
+
* Each round APPENDS its new questions after the digest retained from the previous ones (backend
|
|
132
|
+
* `applyInterviewQuestions`: `[...retainedQa, ...pending]`), so from round two onwards the only
|
|
133
|
+
* questions the human still has to act on sit below a growing wall of ones they already settled —
|
|
134
|
+
* on a long interview, below the fold entirely. This reorders the RENDER only; the stored `qa`
|
|
135
|
+
* order, which the interviewer prompt and the in-repo tracker digest read, is untouched.
|
|
136
|
+
*/
|
|
137
|
+
export function orderInterviewQuestions<T extends Partial<Pick<InitiativeQa, 'answer' | 'status'>>>(
|
|
138
|
+
qa: readonly T[],
|
|
139
|
+
): T[] {
|
|
140
|
+
const pending: T[] = []
|
|
141
|
+
const settled: T[] = []
|
|
142
|
+
for (const q of qa) (isPendingQuestion(q) ? pending : settled).push(q)
|
|
143
|
+
return [...pending, ...settled]
|
|
144
|
+
}
|
|
145
|
+
|
|
114
146
|
/**
|
|
115
147
|
* The phase whose completed checkpoint (D2) is awaiting a human, or null. Mirrors the backend
|
|
116
148
|
* `pendingCheckpoint` (orchestration `initiative.logic.ts`) so the SPA recomputes the pending
|