@cat-factory/app 0.180.0 → 0.182.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 +8 -2
- package/app/components/board/nodes/DraggableTask.vue +3 -2
- package/app/components/board/nodes/TaskPipelineMini.vue +9 -12
- package/app/components/docs/DocInterviewWindow.vue +24 -3
- package/app/components/documents/TaskContextDocs.vue +20 -4
- package/app/components/initiative/InitiativePlanningWindow.vue +141 -77
- package/app/components/initiative/InitiativeTrackerWindow.vue +408 -366
- package/app/components/tasks/TaskContextIssues.vue +22 -4
- package/app/composables/useInitiativePlanning.ts +26 -8
- package/app/composables/useResultViewRunMeta.spec.ts +74 -0
- package/app/composables/useResultViewRunMeta.ts +98 -0
- package/app/composables/useTaskExpansion.ts +30 -16
- 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 +15 -6
- package/app/stores/taskExpansion.spec.ts +88 -0
- package/app/stores/taskExpansion.ts +34 -14
- package/app/utils/catalog.ts +5 -4
- package/app/utils/interviewGate.spec.ts +87 -12
- package/app/utils/interviewGate.ts +57 -5
- package/i18n/locales/de.json +10 -2
- package/i18n/locales/en.json +10 -2
- package/i18n/locales/es.json +10 -2
- package/i18n/locales/fr.json +10 -2
- package/i18n/locales/he.json +10 -2
- package/i18n/locales/it.json +10 -2
- package/i18n/locales/ja.json +10 -2
- package/i18n/locales/pl.json +10 -2
- package/i18n/locales/tr.json +10 -2
- package/i18n/locales/uk.json +10 -2
- package/package.json +1 -1
|
@@ -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>
|
|
@@ -4,7 +4,11 @@ import { useExecutionStore } from '~/stores/execution'
|
|
|
4
4
|
import { useInitiativesStore } from '~/stores/initiative'
|
|
5
5
|
import { usePipelinesStore } from '~/stores/pipelines'
|
|
6
6
|
import { useUiStore } from '~/stores/ui'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
INITIATIVE_INTERVIEWER_KIND,
|
|
9
|
+
interviewGatePhase,
|
|
10
|
+
interviewStepReached,
|
|
11
|
+
} from '~/utils/interviewGate'
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* Shared planning affordances for an `initiative`-level block, used by BOTH the board card
|
|
@@ -41,12 +45,14 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
41
45
|
* The live interview phase, derived from the entity AND the planning run (see
|
|
42
46
|
* {@link interviewGatePhase} for why the run status is load-bearing).
|
|
43
47
|
*/
|
|
44
|
-
const interviewPhase = computed(() =>
|
|
45
|
-
|
|
48
|
+
const interviewPhase = computed(() => {
|
|
49
|
+
const run = execution.getByBlock(toValue(blockId))
|
|
50
|
+
return interviewGatePhase(
|
|
46
51
|
initiative.value?.interview?.status,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
run?.status,
|
|
53
|
+
interviewStepReached(run, INITIATIVE_INTERVIEWER_KIND),
|
|
54
|
+
)
|
|
55
|
+
})
|
|
50
56
|
|
|
51
57
|
/**
|
|
52
58
|
* The interviewer has PARKED the planning run for the human. NOT keyed on whether individual
|
|
@@ -62,8 +68,20 @@ export function useInitiativePlanning(blockId: MaybeRefOrGetter<string>) {
|
|
|
62
68
|
*/
|
|
63
69
|
const awaitingAnswers = computed(() => interviewPhase.value === 'awaiting')
|
|
64
70
|
|
|
65
|
-
/**
|
|
66
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The planning run is mid-flight with nothing for the human to answer — either an interviewer
|
|
73
|
+
* pass is running (`working`) or the run has not reached the interview yet (`preparing`, the
|
|
74
|
+
* codebase analysis that now leads). BOTH phases, deliberately: this drives the card's and
|
|
75
|
+
* inspector's "Planning in progress" button, which is the only route into the planning window
|
|
76
|
+
* while a run owns the block. Narrowing it to `working` would drop that route for the whole of
|
|
77
|
+
* the analysis and fall through to a "Run planning" button for a run already running.
|
|
78
|
+
*
|
|
79
|
+
* The two phases are distinguished INSIDE the window, where there is room to say which is which;
|
|
80
|
+
* the affordance is the same either way.
|
|
81
|
+
*/
|
|
82
|
+
const interviewing = computed(
|
|
83
|
+
() => interviewPhase.value === 'working' || interviewPhase.value === 'preparing',
|
|
84
|
+
)
|
|
67
85
|
|
|
68
86
|
/**
|
|
69
87
|
* Optimistic start flag: flip true the instant "Run planning" is clicked, before the stream
|
|
@@ -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
|
+
}
|
|
@@ -15,22 +15,24 @@ function sameSet(a: Set<string>, b: Set<string>) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
* Board-level driver deciding which task cards
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Board-level driver deciding which task cards expand their full pipeline list.
|
|
19
|
+
* Recomputed every frame against live DOM rects so it follows pan / zoom / drag / resize,
|
|
20
|
+
* and writes two independent grants into the `taskExpansion` store (which combines them —
|
|
21
|
+
* see the store for how they resolve):
|
|
21
22
|
*
|
|
22
|
-
* -
|
|
23
|
-
*
|
|
24
|
-
* task expands only if its footprint doesn't collide with one already granted, so the
|
|
25
|
-
* card you're looking at wins an overlap and the rest stay compact.
|
|
26
|
-
* - hover: the task directly under the pointer is granted first, so hovering any card
|
|
27
|
-
* expands its pipeline regardless of its position on screen. "Under the pointer" is
|
|
28
|
-
* the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
|
|
23
|
+
* - hover: the task directly under the pointer, at ANY zoom level. "Under the pointer"
|
|
24
|
+
* is the TOPMOST card at the cursor (document.elementFromPoint), so hovering a region
|
|
29
25
|
* already covered by another open pipeline keeps that pipeline, not the card beneath.
|
|
26
|
+
* - zoom: at the deep `steps`/`subtasks` bands, every on-screen card, minus overlaps —
|
|
27
|
+
* two sub-gates:
|
|
28
|
+
* - visibility: a task expands only while its card overlaps the board viewport.
|
|
29
|
+
* - overlap: walking the visible candidates nearest-header-to-screen-centre first, a
|
|
30
|
+
* task expands only if its footprint doesn't collide with one already granted, so
|
|
31
|
+
* the card you're looking at wins an overlap and the rest stay compact. The hovered
|
|
32
|
+
* card is granted first, so it wins every overlap it's part of.
|
|
30
33
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* wouldn't expand never blocks a neighbour.
|
|
34
|
+
* Only tasks with a running pipeline (steps to show) are candidates for either grant — a
|
|
35
|
+
* task that wouldn't expand never blocks a neighbour and never lifts an empty card.
|
|
34
36
|
*/
|
|
35
37
|
export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
36
38
|
const board = useBoardStore()
|
|
@@ -65,14 +67,27 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
65
67
|
// The task whose card is topmost at the pointer, or null. Using elementFromPoint (not a
|
|
66
68
|
// rect test) means an open pipeline stacked above a neighbour wins the hit, so hovering
|
|
67
69
|
// a region obscured by another pipeline doesn't switch to the card hidden beneath it.
|
|
70
|
+
//
|
|
71
|
+
// Blocks with no pipeline to show are filtered out here rather than left to the card:
|
|
72
|
+
// a frame, a module, or a task with no run expands to nothing, and granting it would
|
|
73
|
+
// still lift an empty card over its neighbours (see DraggableTask's z-index).
|
|
68
74
|
function hoveredTaskId(): string | null {
|
|
69
75
|
if (!pointer) return null
|
|
70
76
|
const hit = document.elementFromPoint(pointer.x, pointer.y)
|
|
71
|
-
|
|
77
|
+
const id = hit?.closest('[data-block-id]')?.getAttribute('data-block-id') ?? null
|
|
78
|
+
if (!id || !execution.getByBlock(id)?.steps.length) return null
|
|
79
|
+
return id
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
function recompute() {
|
|
75
|
-
//
|
|
83
|
+
// Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
|
|
84
|
+
// zoom gate below — resolving it after would collapse the hovered card the moment the
|
|
85
|
+
// user zoomed back out past the `steps` band.
|
|
86
|
+
const hovered = hoveredTaskId()
|
|
87
|
+
if (store.hoveredId !== hovered) store.setHovered(hovered)
|
|
88
|
+
|
|
89
|
+
// The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
|
|
90
|
+
// only; clear its grants otherwise. The hover grant above stands on its own.
|
|
76
91
|
if (!lodAtLeast(ui.lod, 'steps')) {
|
|
77
92
|
if (store.allowed.size) store.setAllowed(new Set())
|
|
78
93
|
return
|
|
@@ -117,7 +132,6 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
|
|
|
117
132
|
// clears every footprint already granted, so the centre-most card wins any overlap.
|
|
118
133
|
// The hovered card is granted FIRST, so hovering a card expands it regardless of its
|
|
119
134
|
// distance from the centre (and a centre-most neighbour it overlaps yields to it).
|
|
120
|
-
const hovered = hoveredTaskId()
|
|
121
135
|
const claimed: Rect[] = []
|
|
122
136
|
const next = new Set<string>()
|
|
123
137
|
const hoveredCard = hovered ? candidates.find((c) => c.id === hovered) : undefined
|
|
@@ -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', () => {
|
|
@@ -76,12 +76,20 @@ const isTask = (b: Block) => b.level === 'task'
|
|
|
76
76
|
const isFrame = (b: Block) => b.level === 'frame'
|
|
77
77
|
/**
|
|
78
78
|
* A block whose inspector carries a pipeline RUN. An initiative's planning pipeline is an
|
|
79
|
-
* ordinary run of ordinary agent steps (
|
|
79
|
+
* ordinary run of ordinary agent steps (analyst → interviewer → planner → committer), so it
|
|
80
80
|
* gets the same execution panel a task does — step list, live phases, step-detail drill-down,
|
|
81
81
|
* and the Stop / Discard-run controls that are the only way to unwedge a stalled planning run.
|
|
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
|
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { useTaskExpansionStore } from '~/stores/taskExpansion'
|
|
3
|
+
import { useUiStore } from '~/stores/ui'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The expansion gate combines two independent grants (hover at any zoom, the deep zoom
|
|
7
|
+
* bands otherwise). Both `TaskPipelineMini` (what renders) and `DraggableTask` (what
|
|
8
|
+
* stacks on top) read `isExpanded`, so these cases pin the rule they share.
|
|
9
|
+
*
|
|
10
|
+
* Zoom is set through the ui store's raw `zoom`, the same value the board canvas writes;
|
|
11
|
+
* `lod` derives from it (< 1.8 is shallower than the `steps` band).
|
|
12
|
+
*/
|
|
13
|
+
function setup(zoom: number) {
|
|
14
|
+
const ui = useUiStore()
|
|
15
|
+
ui.zoom = zoom
|
|
16
|
+
const store = useTaskExpansionStore()
|
|
17
|
+
store.setDriverActive(true)
|
|
18
|
+
return store
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('taskExpansion — hover expands at any zoom level', () => {
|
|
22
|
+
it('expands the hovered card while zoomed out past the steps band', () => {
|
|
23
|
+
const store = setup(0.5) // `far`
|
|
24
|
+
store.setHovered('task-a')
|
|
25
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
26
|
+
// Hover is a grant of ONE card: its neighbours stay compact at this zoom.
|
|
27
|
+
expect(store.isExpanded('task-b')).toBe(false)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('expands the hovered card at every band, not just the shallow ones', () => {
|
|
31
|
+
const store = setup(1) // `mid`
|
|
32
|
+
store.setHovered('task-a')
|
|
33
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
34
|
+
useUiStore().zoom = 1.5 // `close`
|
|
35
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
36
|
+
useUiStore().zoom = 3 // `subtasks`
|
|
37
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('collapses again once the pointer leaves the card', () => {
|
|
41
|
+
const store = setup(0.5)
|
|
42
|
+
store.setHovered('task-a')
|
|
43
|
+
store.setHovered(null)
|
|
44
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('expands a hovered card the zoom gate denied for overlapping a neighbour', () => {
|
|
48
|
+
const store = setup(2) // `steps`
|
|
49
|
+
store.setAllowed(new Set(['task-b']))
|
|
50
|
+
store.setHovered('task-a')
|
|
51
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
52
|
+
expect(store.isExpanded('task-b')).toBe(true)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('taskExpansion — the zoom grant is unchanged', () => {
|
|
57
|
+
it('honours the driver grant only from the steps band up', () => {
|
|
58
|
+
const store = setup(1.5) // `close` — one band shallower than `steps`
|
|
59
|
+
store.setAllowed(new Set(['task-a']))
|
|
60
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
61
|
+
useUiStore().zoom = 2 // `steps`
|
|
62
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('denies a card the driver left out of the permitted set', () => {
|
|
66
|
+
const store = setup(2)
|
|
67
|
+
store.setAllowed(new Set(['task-b']))
|
|
68
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('falls back to allowed with no driver mounted, still only at the deep bands', () => {
|
|
72
|
+
const ui = useUiStore()
|
|
73
|
+
ui.zoom = 2
|
|
74
|
+
const store = useTaskExpansionStore() // driverActive stays false
|
|
75
|
+
expect(store.isExpanded('task-a')).toBe(true)
|
|
76
|
+
ui.zoom = 1
|
|
77
|
+
expect(store.isExpanded('task-a')).toBe(false)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('drops both grants when the driver unmounts', () => {
|
|
81
|
+
const store = setup(2)
|
|
82
|
+
store.setAllowed(new Set(['task-a']))
|
|
83
|
+
store.setHovered('task-b')
|
|
84
|
+
store.setDriverActive(false)
|
|
85
|
+
expect(store.allowed.size).toBe(0)
|
|
86
|
+
expect(store.hoveredId).toBeNull()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -1,37 +1,57 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref } from 'vue'
|
|
3
|
+
import { lodAtLeast } from '~/composables/useSemanticZoom'
|
|
4
|
+
import { useUiStore } from '~/stores/ui'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
|
-
* Which task cards
|
|
7
|
+
* Which task cards render their full build-pipeline list.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* recomputes a permitted set every frame — only on-screen cards, the one closest to
|
|
11
|
-
* the screen centre when two would overlap, plus whichever card the pointer is hovering
|
|
12
|
-
* (hover expands a card regardless of position) — and writes it here.
|
|
13
|
-
* `TaskPipelineMini` reads `canExpand` to decide whether to expand or stay compact.
|
|
9
|
+
* Two independent grants, both written every frame by the board driver
|
|
10
|
+
* (`useTaskExpansion`) and combined HERE so the render (`TaskPipelineMini`) and the
|
|
11
|
+
* stacking (`DraggableTask`) can never disagree about which cards are expanded:
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* - HOVER — the card under the pointer expands at ANY zoom level. Pointing at a task
|
|
14
|
+
* is asking what it is doing right now, and that answer used to be reachable only
|
|
15
|
+
* by first zooming past the `steps` band.
|
|
16
|
+
* - ZOOM — past the `steps` band, every on-screen card expands. Deep-zoom grows a card
|
|
17
|
+
* downward and cards are absolutely positioned in their frame, so several expanded
|
|
18
|
+
* cards stacked vertically pile on top of each other; the driver denies whichever
|
|
19
|
+
* would collide with a card nearer the screen centre and writes the survivors here.
|
|
20
|
+
*
|
|
21
|
+
* `driverActive` lets the zoom grant degrade gracefully: with no board driver mounted
|
|
22
|
+
* (e.g. a card rendered in isolation) it falls back to "allowed", so the plain zoom
|
|
23
|
+
* behaviour is unchanged. The hover grant needs no fallback — without the driver
|
|
24
|
+
* nothing is hovered.
|
|
18
25
|
*/
|
|
19
26
|
export const useTaskExpansionStore = defineStore('taskExpansion', () => {
|
|
27
|
+
const ui = useUiStore()
|
|
20
28
|
const allowed = ref<Set<string>>(new Set())
|
|
29
|
+
const hoveredId = ref<string | null>(null)
|
|
21
30
|
const driverActive = ref(false)
|
|
22
31
|
|
|
23
32
|
function setAllowed(ids: Set<string>) {
|
|
24
33
|
allowed.value = ids
|
|
25
34
|
}
|
|
26
35
|
|
|
36
|
+
function setHovered(id: string | null) {
|
|
37
|
+
hoveredId.value = id
|
|
38
|
+
}
|
|
39
|
+
|
|
27
40
|
function setDriverActive(active: boolean) {
|
|
28
41
|
driverActive.value = active
|
|
29
|
-
if (!active)
|
|
42
|
+
if (!active) {
|
|
43
|
+
allowed.value = new Set()
|
|
44
|
+
hoveredId.value = null
|
|
45
|
+
}
|
|
30
46
|
}
|
|
31
47
|
|
|
32
|
-
|
|
48
|
+
/** Whether this task card should render its pipeline: hovered at any zoom, else
|
|
49
|
+
* granted by the deep-zoom bands. */
|
|
50
|
+
function isExpanded(id: string) {
|
|
51
|
+
if (hoveredId.value === id) return true
|
|
52
|
+
if (!lodAtLeast(ui.lod, 'steps')) return false
|
|
33
53
|
return driverActive.value ? allowed.value.has(id) : true
|
|
34
54
|
}
|
|
35
55
|
|
|
36
|
-
return { allowed, driverActive, setAllowed, setDriverActive,
|
|
56
|
+
return { allowed, hoveredId, driverActive, setAllowed, setHovered, setDriverActive, isExpanded }
|
|
37
57
|
})
|