@cat-factory/app 0.283.0 → 0.285.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 +19 -0
- package/app/components/board/BoardCanvas.logic.spec.ts +71 -0
- package/app/components/board/BoardCanvas.logic.ts +92 -0
- package/app/components/board/BoardCanvas.vue +14 -27
- package/app/components/board/nodes/TaskPipelineMini.vue +2 -1
- package/app/components/followUp/FollowUpWindow.vue +49 -9
- package/app/components/panels/AgentStepDetail.vue +27 -1
- package/app/components/panels/ResultWindowShell.vue +19 -0
- package/app/components/panels/RunDetailLoadState.vue +41 -0
- package/app/components/panels/inspector/TaskExecution.vue +3 -3
- package/app/components/pipeline/PipelineProgress.vue +7 -3
- package/app/components/settings/KubernetesEngineForm.vue +1 -1
- package/app/components/settings/KubernetesEnvironmentForm.vue +1 -1
- package/app/composables/api/execution.ts +12 -0
- package/app/composables/api/followUps.ts +11 -3
- package/app/composables/useBlockDrag.ts +51 -5
- package/app/composables/useSingleFlight.spec.ts +42 -0
- package/app/composables/useSingleFlight.ts +37 -0
- package/app/composables/useStepApproval.ts +19 -0
- package/app/composables/useStepTimer.ts +70 -14
- package/app/composables/useUpsertList.spec.ts +73 -0
- package/app/composables/useUpsertList.ts +52 -6
- package/app/composables/useViewport.ts +13 -3
- package/app/stores/consensus.ts +8 -1
- package/app/stores/docInterview.ts +10 -1
- package/app/stores/execution/reconcile.ts +182 -0
- package/app/stores/execution/wholeRunReads.ts +139 -0
- package/app/stores/execution.spec.ts +297 -1
- package/app/stores/execution.ts +57 -110
- package/app/stores/followUps.spec.ts +94 -0
- package/app/stores/followUps.ts +17 -3
- package/app/stores/kaizen.spec.ts +77 -14
- package/app/stores/kaizen.ts +75 -17
- package/app/stores/notifications.spec.ts +65 -0
- package/app/stores/notifications.ts +29 -0
- package/app/stores/observability/agentContext.ts +128 -0
- package/app/stores/observability/toolCalls.ts +30 -2
- package/app/stores/observability.spec.ts +98 -0
- package/app/stores/observability.ts +51 -79
- package/app/stores/requirements/settlement.ts +55 -0
- package/app/stores/requirements.ts +25 -23
- package/app/stores/ui/k3sDeepLink.spec.ts +5 -5
- package/app/stores/workspace/hydrate.ts +11 -0
- package/app/stores/workspace/refreshFunnel.spec.ts +15 -1
- package/app/types/execution.ts +1 -0
- package/i18n/locales/de.json +10 -1
- package/i18n/locales/en.json +10 -1
- package/i18n/locales/es.json +10 -1
- package/i18n/locales/fr.json +10 -1
- package/i18n/locales/he.json +10 -1
- package/i18n/locales/it.json +10 -1
- package/i18n/locales/ja.json +10 -1
- package/i18n/locales/pl.json +10 -1
- package/i18n/locales/tr.json +10 -1
- package/i18n/locales/uk.json +10 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1030,6 +1030,25 @@ event left to restore it.
|
|
|
1030
1030
|
(`refreshMark()` / `hydratedSince()`) and stands down if so, which is what stops a mutation that
|
|
1031
1031
|
refreshes directly AND raises a coarse event from paying for two snapshots. That skip rests on
|
|
1032
1032
|
the server emitting a coarse `board` event only after committing what it announces.
|
|
1033
|
+
- **The board snapshot's runs are a LEAN PROJECTION, so a hydrate can WITHHOLD what it does not
|
|
1034
|
+
clobber.** `projectExecutionForBoard` (contracts) strips each step's captured prose from the
|
|
1035
|
+
snapshot's executions and stamps the instance `projected`; a live `execution` event still carries
|
|
1036
|
+
the whole run. Two rules follow, and both are the clobber rule in a new shape. A step-detail
|
|
1037
|
+
surface fetches the run before rendering (`execution.ensureFull`, asked by the two overlay HOSTS
|
|
1038
|
+
so no window can forget) and reads `stepHasOutput`, never `step.output`, for the "there is prose
|
|
1039
|
+
here" affordance. And the store's reconcile carries the withheld fields forward ONLY at an equal
|
|
1040
|
+
`rev`: at the same revision the withheld prose IS what the cache holds, one revision later it may
|
|
1041
|
+
not be, so a newer projection replaces and the open overlay re-fetches.
|
|
1042
|
+
- **`execution.instances` is a `shallowRef`, so a write must both TRIGGER and change IDENTITY.**
|
|
1043
|
+
Nothing under the ref is a reactive proxy any more, so the only dependency a reader can hold is
|
|
1044
|
+
the ref itself, and nearly every reader holds it through an identity-stable chain
|
|
1045
|
+
(`computed(() => getInstance(id))` to `steps[i]` to one field). `triggerRef` re-runs the FIRST
|
|
1046
|
+
computed in that chain, and Vue stops propagating when its recomputed value is `===` the previous
|
|
1047
|
+
one, so a run patched in place reaches that computed and nothing below it. `upsert` replaces the
|
|
1048
|
+
run object; `echoAfter` applies the action store's patch to a COPY of the run and its steps and
|
|
1049
|
+
swaps that in. A missing trigger and an in-place patch are both SILENT, so
|
|
1050
|
+
`stores/execution.spec.ts` pins each write shape twice: once on the array, once through the
|
|
1051
|
+
`getInstance` chain a window actually reads.
|
|
1033
1052
|
- **Pin it with a store-level unit test** (`stores/workspace.spec.ts` for refreshes,
|
|
1034
1053
|
`stores/workspace/refreshFunnel.spec.ts` for the funnel's own rules, `stores/execution.spec.ts`
|
|
1035
1054
|
for echoes): drive the two orderings and assert the fresher one wins.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
createBoardNodeProjection,
|
|
4
|
+
frameZIndex,
|
|
5
|
+
type BoardNodeSource,
|
|
6
|
+
} from '~/components/board/BoardCanvas.logic'
|
|
7
|
+
|
|
8
|
+
const NO_STACK = { draggingId: null, hoveredFrameId: null }
|
|
9
|
+
|
|
10
|
+
function at(id: string, x = 0, y = 0): BoardNodeSource {
|
|
11
|
+
return { id, position: { x, y } }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('frameZIndex', () => {
|
|
15
|
+
it('lifts the dragged frame above the hovered one, and both above the rest', () => {
|
|
16
|
+
const stacking = { draggingId: 'dragged', hoveredFrameId: 'hovered' }
|
|
17
|
+
expect(frameZIndex('dragged', stacking)).toBeGreaterThan(frameZIndex('hovered', stacking))
|
|
18
|
+
expect(frameZIndex('hovered', stacking)).toBeGreaterThan(frameZIndex('other', stacking))
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('createBoardNodeProjection', () => {
|
|
23
|
+
it('projects frames and epics with the fields the canvas binds', () => {
|
|
24
|
+
const project = createBoardNodeProjection()
|
|
25
|
+
const [frame, epic] = project([at('f1', 10, 20)], [at('e1', 30, 40)], NO_STACK)
|
|
26
|
+
expect(frame).toMatchObject({
|
|
27
|
+
id: 'f1',
|
|
28
|
+
type: 'block',
|
|
29
|
+
position: { x: 10, y: 20 },
|
|
30
|
+
draggable: false,
|
|
31
|
+
zIndex: 1,
|
|
32
|
+
})
|
|
33
|
+
expect(epic).toMatchObject({
|
|
34
|
+
id: 'e1',
|
|
35
|
+
type: 'epic',
|
|
36
|
+
position: { x: 30, y: 40 },
|
|
37
|
+
draggable: true,
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// The whole point of the memo: a hover changes two nodes, so it must allocate two nodes.
|
|
42
|
+
it('reuses every node a hover did not change', () => {
|
|
43
|
+
const project = createBoardNodeProjection()
|
|
44
|
+
const frames = [at('a'), at('b'), at('c')]
|
|
45
|
+
const before = project(frames, [], NO_STACK)
|
|
46
|
+
const after = project(frames, [], { draggingId: null, hoveredFrameId: 'b' })
|
|
47
|
+
|
|
48
|
+
expect(after[0]).toBe(before[0])
|
|
49
|
+
expect(after[2]).toBe(before[2])
|
|
50
|
+
expect(after[1]).not.toBe(before[1])
|
|
51
|
+
expect(after[1]!.zIndex).toBeGreaterThan(before[1]!.zIndex!)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('rebuilds a node whose position moved', () => {
|
|
55
|
+
const project = createBoardNodeProjection()
|
|
56
|
+
const before = project([at('a', 0, 0)], [], NO_STACK)
|
|
57
|
+
const after = project([at('a', 5, 0)], [], NO_STACK)
|
|
58
|
+
expect(after[0]).not.toBe(before[0])
|
|
59
|
+
expect(after[0]!.position).toEqual({ x: 5, y: 0 })
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not keep a removed frame alive in the memo', () => {
|
|
63
|
+
const project = createBoardNodeProjection()
|
|
64
|
+
const first = project([at('a'), at('b')], [], NO_STACK)
|
|
65
|
+
project([at('b')], [], NO_STACK)
|
|
66
|
+
// 'a' is gone from the cache, so its return is a fresh object rather than the stale one.
|
|
67
|
+
const back = project([at('a'), at('b')], [], NO_STACK)
|
|
68
|
+
expect(back[0]).not.toBe(first[0])
|
|
69
|
+
expect(back.map((n) => n.id)).toEqual(['a', 'b'])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The board's Vue Flow node projection, and the memo that keeps a hover cheap.
|
|
3
|
+
*
|
|
4
|
+
* Only frames and epics are canvas nodes (tasks live inside their frame, laid out in swimlanes).
|
|
5
|
+
* The projection is trivial, but it is recomputed for a reason that has nothing to do with the
|
|
6
|
+
* board changing: a frame's `zIndex` encodes the STACKING state (which frame is being dragged,
|
|
7
|
+
* which is hovered), so every pointer move between two overlapping services re-derived the whole
|
|
8
|
+
* array, and Vue Flow re-diffed every node in it against a set of freshly allocated objects that
|
|
9
|
+
* were, for all but one or two, identical to the ones it already held.
|
|
10
|
+
*
|
|
11
|
+
* Memoising per node fixes that without changing what the array contains: the key carries every
|
|
12
|
+
* field the node has, so a node whose fields are unchanged comes back as the SAME object and a
|
|
13
|
+
* changed one is rebuilt. A hover then allocates two nodes instead of all of them, and Vue Flow's
|
|
14
|
+
* diff sees two changes instead of N.
|
|
15
|
+
*
|
|
16
|
+
* The cache is rebuilt from the hits of each pass, so a deleted frame's entry does not outlive it.
|
|
17
|
+
*/
|
|
18
|
+
export interface BoardNodeSource {
|
|
19
|
+
id: string
|
|
20
|
+
position: { x: number; y: number }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface BoardFlowNode {
|
|
24
|
+
id: string
|
|
25
|
+
type: 'block' | 'epic'
|
|
26
|
+
position: { x: number; y: number }
|
|
27
|
+
draggable: boolean
|
|
28
|
+
zIndex?: number
|
|
29
|
+
data: Record<string, never>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Where a frame sits in the stack: dragged on top, then hovered, then everything else. */
|
|
33
|
+
export interface FrameStacking {
|
|
34
|
+
draggingId: string | null
|
|
35
|
+
hoveredFrameId: string | null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Vue Flow's `elevate-nodes-on-select` is OFF (see BoardCanvas), so stacking is driven purely by
|
|
40
|
+
* these two: the frame being dragged is lifted above all, then the hovered one (the un-obscured
|
|
41
|
+
* frame under the pointer), so overlapping services can always be reached and reordered.
|
|
42
|
+
*/
|
|
43
|
+
export function frameZIndex(id: string, stacking: FrameStacking): number {
|
|
44
|
+
if (stacking.draggingId === id) return 1000
|
|
45
|
+
if (stacking.hoveredFrameId === id) return 100
|
|
46
|
+
return 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createBoardNodeProjection() {
|
|
50
|
+
let cache = new Map<string, BoardFlowNode>()
|
|
51
|
+
|
|
52
|
+
return function project(
|
|
53
|
+
frames: readonly BoardNodeSource[],
|
|
54
|
+
epics: readonly BoardNodeSource[],
|
|
55
|
+
stacking: FrameStacking,
|
|
56
|
+
): BoardFlowNode[] {
|
|
57
|
+
const next = new Map<string, BoardFlowNode>()
|
|
58
|
+
const nodes: BoardFlowNode[] = []
|
|
59
|
+
|
|
60
|
+
const take = (key: string, build: () => BoardFlowNode) => {
|
|
61
|
+
const node = cache.get(key) ?? build()
|
|
62
|
+
next.set(key, node)
|
|
63
|
+
nodes.push(node)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const frame of frames) {
|
|
67
|
+
const zIndex = frameZIndex(frame.id, stacking)
|
|
68
|
+
take(`block|${frame.id}|${frame.position.x}|${frame.position.y}|${zIndex}`, () => ({
|
|
69
|
+
id: frame.id,
|
|
70
|
+
type: 'block',
|
|
71
|
+
position: { x: frame.position.x, y: frame.position.y },
|
|
72
|
+
// Always-expanded frames fill the viewport; keep them non-draggable so the pane pans
|
|
73
|
+
// through them (they move via their header handle, see BlockNode).
|
|
74
|
+
draggable: false,
|
|
75
|
+
zIndex,
|
|
76
|
+
data: {},
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
for (const epic of epics) {
|
|
80
|
+
take(`epic|${epic.id}|${epic.position.x}|${epic.position.y}`, () => ({
|
|
81
|
+
id: epic.id,
|
|
82
|
+
type: 'epic',
|
|
83
|
+
position: { x: epic.position.x, y: epic.position.y },
|
|
84
|
+
draggable: true,
|
|
85
|
+
data: {},
|
|
86
|
+
}))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
cache = next
|
|
90
|
+
return nodes
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -14,6 +14,7 @@ import { useFrameStacking } from '~/composables/useFrameStacking'
|
|
|
14
14
|
import { useFramePlacement } from '~/composables/useFramePlacement'
|
|
15
15
|
import { useViewport } from '~/composables/useViewport'
|
|
16
16
|
import { boardPanMode } from '~/utils/boardPanMode'
|
|
17
|
+
import { createBoardNodeProjection } from './BoardCanvas.logic'
|
|
17
18
|
|
|
18
19
|
const board = useBoardStore()
|
|
19
20
|
const pipelines = usePipelinesStore()
|
|
@@ -70,33 +71,19 @@ useTaskExpansion(boardEl, boardActivity)
|
|
|
70
71
|
// default adds +1000 to a selected node's z-index, so a frame stayed pinned on top
|
|
71
72
|
// after a click and no amount of hovering another frame could surface it. Stacking
|
|
72
73
|
// is driven purely by hover/drag here; the selection highlight is the ring, not z.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
draggable: false,
|
|
87
|
-
zIndex: frameZIndex(b.id),
|
|
88
|
-
data: {},
|
|
89
|
-
})),
|
|
90
|
-
// Epics are top-level grouping nodes (non-structural), drawn alongside frames and
|
|
91
|
-
// linked to their member tasks by the dependency-edge overlay.
|
|
92
|
-
...board.epics.map((b) => ({
|
|
93
|
-
id: b.id,
|
|
94
|
-
type: 'epic',
|
|
95
|
-
position: { x: b.position.x, y: b.position.y },
|
|
96
|
-
draggable: true,
|
|
97
|
-
data: {},
|
|
98
|
-
})),
|
|
99
|
-
])
|
|
74
|
+
//
|
|
75
|
+
// The projection is MEMOISED per node (`BoardCanvas.logic.ts`) because the stacking state is
|
|
76
|
+
// what changes most often: a pointer moving between two overlapping services rebuilt every
|
|
77
|
+
// node object on the board, for a change that concerns two of them. Epics are top-level
|
|
78
|
+
// grouping nodes (non-structural), drawn alongside frames and linked to their member tasks by
|
|
79
|
+
// the dependency-edge overlay.
|
|
80
|
+
const projectNodes = createBoardNodeProjection()
|
|
81
|
+
const nodes = computed(() =>
|
|
82
|
+
projectNodes(board.frames, board.epics, {
|
|
83
|
+
draggingId: draggingId.value,
|
|
84
|
+
hoveredFrameId: hoveredFrameId.value,
|
|
85
|
+
}),
|
|
86
|
+
)
|
|
100
87
|
|
|
101
88
|
onNodeDragStop(({ node }) => {
|
|
102
89
|
board.moveBlock(node.id, node.position)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { AgentState, PipelineStep } from '~/types/domain'
|
|
3
|
+
import { stepHasOutput } from '@cat-factory/contracts'
|
|
3
4
|
import { agentKindMeta } from '~/utils/catalog'
|
|
4
5
|
import {
|
|
5
6
|
subtaskIconClass,
|
|
@@ -120,7 +121,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
120
121
|
{{ agentKindMeta(s.agentKind).label }}
|
|
121
122
|
</span>
|
|
122
123
|
<UIcon
|
|
123
|
-
v-if="s
|
|
124
|
+
v-if="stepHasOutput(s)"
|
|
124
125
|
name="i-lucide-file-text"
|
|
125
126
|
class="h-2.5 w-2.5 shrink-0 text-slate-500"
|
|
126
127
|
/>
|
|
@@ -10,7 +10,7 @@ import { useResultView } from '~/composables/useResultView'
|
|
|
10
10
|
import { useExecutionStore } from '~/stores/execution'
|
|
11
11
|
import { useBoardStore } from '~/stores/board'
|
|
12
12
|
import { useFollowUpsStore } from '~/stores/followUps'
|
|
13
|
-
import type { FollowUpItem } from '~/types/execution'
|
|
13
|
+
import type { FollowUpItem, FollowUpResolution } from '~/types/execution'
|
|
14
14
|
import { FOLLOW_UP_COMPANION_META } from '~/utils/catalog'
|
|
15
15
|
import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
|
|
16
16
|
|
|
@@ -56,14 +56,23 @@ async function onQueue(item: FollowUpItem) {
|
|
|
56
56
|
const id = execId()
|
|
57
57
|
if (id) await followUps.queueItem(id, item.id).catch(() => {})
|
|
58
58
|
}
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Answer a question, either way.
|
|
61
|
+
*
|
|
62
|
+
* `answered` buys the Coder another pass to apply what you said. `closed` records the reply as a
|
|
63
|
+
* RULING: the item is decided and the gate clears exactly the same, but no pass is spent and the
|
|
64
|
+
* Coder is told the topic is settled so it stops re-raising it. Two buttons rather than one with a
|
|
65
|
+
* toggle, because the choice is not a preference about this window: it is what the answer IS, and
|
|
66
|
+
* the person typing it is the only one who knows.
|
|
67
|
+
*/
|
|
68
|
+
async function onAnswer(item: FollowUpItem, resolution: FollowUpResolution = 'answered') {
|
|
60
69
|
const id = execId()
|
|
61
70
|
const answer = (drafts[item.id] ?? '').trim()
|
|
62
71
|
if (!id || !answer) return
|
|
63
72
|
// Clear the draft only once the answer is actually recorded: clearing first would make a failed
|
|
64
73
|
// send cost the typed answer, and would also leave the unsaved guard below with nothing to protect.
|
|
65
74
|
await followUps
|
|
66
|
-
.answerItem(id, item.id, answer)
|
|
75
|
+
.answerItem(id, item.id, answer, resolution)
|
|
67
76
|
.then(() => {
|
|
68
77
|
delete drafts[item.id]
|
|
69
78
|
})
|
|
@@ -103,6 +112,7 @@ const STATUS_LABEL_KEYS: Record<FollowUpItem['status'], string> = {
|
|
|
103
112
|
filed: 'followUp.status.filed',
|
|
104
113
|
queued: 'followUp.status.queued',
|
|
105
114
|
answered: 'followUp.status.answered',
|
|
115
|
+
closed: 'followUp.status.closed',
|
|
106
116
|
dismissed: 'followUp.status.dismissed',
|
|
107
117
|
}
|
|
108
118
|
|
|
@@ -114,8 +124,14 @@ const STATUS_META: Record<
|
|
|
114
124
|
filed: { badge: 'success', text: 'text-emerald-300' },
|
|
115
125
|
queued: { badge: 'info', text: 'text-sky-300' },
|
|
116
126
|
answered: { badge: 'info', text: 'text-sky-300' },
|
|
127
|
+
closed: { badge: 'neutral', text: 'text-slate-300' },
|
|
117
128
|
dismissed: { badge: 'neutral', text: 'text-slate-400' },
|
|
118
129
|
}
|
|
130
|
+
|
|
131
|
+
/** Whether a decided item carries a recorded reply to show ('answered' or 'closed'). */
|
|
132
|
+
function hasRecordedAnswer(item: FollowUpItem): boolean {
|
|
133
|
+
return (item.status === 'answered' || item.status === 'closed') && !!item.answer
|
|
134
|
+
}
|
|
119
135
|
</script>
|
|
120
136
|
|
|
121
137
|
<template>
|
|
@@ -197,13 +213,19 @@ const STATUS_META: Record<
|
|
|
197
213
|
{{ item.ticketExternalId ?? t('followUp.viewIssue') }}
|
|
198
214
|
</a>
|
|
199
215
|
</p>
|
|
200
|
-
<p
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
<p v-if="hasRecordedAnswer(item)" class="mt-1 text-[11px] text-slate-300">
|
|
217
|
+
<span class="text-slate-500">
|
|
218
|
+
{{
|
|
219
|
+
item.status === 'closed' ? t('followUp.yourRuling') : t('followUp.yourAnswer')
|
|
220
|
+
}}
|
|
221
|
+
</span>
|
|
205
222
|
{{ item.answer }}
|
|
206
223
|
</p>
|
|
224
|
+
<!-- A decision that was made and then thrown away when the budget ran out. It must
|
|
225
|
+
not read like one the Coder acted on. -->
|
|
226
|
+
<p v-if="item.sendBackDropped" class="mt-1 text-[11px] text-amber-300">
|
|
227
|
+
{{ t('followUp.sendBackDropped') }}
|
|
228
|
+
</p>
|
|
207
229
|
|
|
208
230
|
<!-- Actions (only while the item is still undecided) -->
|
|
209
231
|
<div v-if="item.status === 'pending'" class="mt-2.5">
|
|
@@ -215,7 +237,10 @@ const STATUS_META: Record<
|
|
|
215
237
|
:placeholder="t('followUp.answerPlaceholder')"
|
|
216
238
|
class="w-full resize-y rounded-md border border-slate-700 bg-slate-950/60 px-2.5 py-1.5 text-[12px] text-slate-100 placeholder:text-slate-600 focus:border-sky-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/60"
|
|
217
239
|
/>
|
|
218
|
-
|
|
240
|
+
<!-- Wraps, like the follow-up row below: three buttons whose labels are two
|
|
241
|
+
words each in English are one long line in most of the other locales, and
|
|
242
|
+
the result window is a narrow panel. -->
|
|
243
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
219
244
|
<UButton
|
|
220
245
|
size="xs"
|
|
221
246
|
color="primary"
|
|
@@ -226,6 +251,21 @@ const STATUS_META: Record<
|
|
|
226
251
|
>
|
|
227
252
|
{{ t('followUp.actions.answerAndSend') }}
|
|
228
253
|
</UButton>
|
|
254
|
+
<UButton
|
|
255
|
+
size="xs"
|
|
256
|
+
color="neutral"
|
|
257
|
+
variant="subtle"
|
|
258
|
+
:loading="followUps.isActing(item.id)"
|
|
259
|
+
:disabled="!(drafts[item.id] ?? '').trim() || !access.canExecuteRuns.value"
|
|
260
|
+
:title="
|
|
261
|
+
access.canExecuteRuns.value
|
|
262
|
+
? t('followUp.actions.answerAndCloseHint')
|
|
263
|
+
: t('access.noRunExecute')
|
|
264
|
+
"
|
|
265
|
+
@click="onAnswer(item, 'closed')"
|
|
266
|
+
>
|
|
267
|
+
{{ t('followUp.actions.answerAndClose') }}
|
|
268
|
+
</UButton>
|
|
229
269
|
<UButton
|
|
230
270
|
size="xs"
|
|
231
271
|
color="neutral"
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
runIsActive,
|
|
29
29
|
} from '~/utils/pipelineRender'
|
|
30
30
|
import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
|
|
31
|
+
import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
|
|
31
32
|
|
|
32
33
|
// Detail overlay for a single pipeline step. Opened by clicking an agent in the
|
|
33
34
|
// inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
|
|
@@ -53,6 +54,24 @@ const instance = computed(() => execution.getInstance(ctx.value?.instanceId))
|
|
|
53
54
|
const step = computed(() =>
|
|
54
55
|
ctx.value ? (instance.value?.steps[ctx.value.stepIndex] ?? null) : null,
|
|
55
56
|
)
|
|
57
|
+
/**
|
|
58
|
+
* The run this overlay is about, and the fetch that makes it WHOLE. The board snapshot carries a
|
|
59
|
+
* lean projection (`projectExecutionForBoard`) whose steps withhold the very prose this reader
|
|
60
|
+
* renders, so opening a step asks for the run behind it. A run the store already holds whole (one
|
|
61
|
+
* a live `execution` event delivered, or one already fetched) costs nothing.
|
|
62
|
+
*
|
|
63
|
+
* Watched through `fullFetchKey` rather than the id, because the run does not stop being a
|
|
64
|
+
* projection once this overlay is open: any full refresh lands a lean projection over it, and at a
|
|
65
|
+
* NEWER revision the store cannot carry the cached prose forward. Keyed on the id, the reader
|
|
66
|
+
* would blank under an open overlay with nothing left to refill it.
|
|
67
|
+
*/
|
|
68
|
+
watch(
|
|
69
|
+
() => execution.fullFetchKey(ctx.value?.instanceId ?? null),
|
|
70
|
+
() => void execution.ensureFull(ctx.value?.instanceId ?? null),
|
|
71
|
+
{
|
|
72
|
+
immediate: true,
|
|
73
|
+
},
|
|
74
|
+
)
|
|
56
75
|
const block = computed(() => (instance.value ? board.getBlock(instance.value.blockId) : undefined))
|
|
57
76
|
const agent = computed(() => (step.value ? agentKindMeta(step.value.agentKind) : null))
|
|
58
77
|
const open = computed(() => !!ctx.value && !!step.value)
|
|
@@ -265,6 +284,9 @@ const approval = useStepApproval({
|
|
|
265
284
|
approvalId: () => approvalId.value,
|
|
266
285
|
approvalPending: () => approvalPending.value && !dedicatedPark.value,
|
|
267
286
|
companionExceeded: () => companionExceeded.value,
|
|
287
|
+
// The editor seeds from the step's prose, which a board projection withholds until the
|
|
288
|
+
// whole-run read above lands.
|
|
289
|
+
runIsWhole: () => instance.value?.projected !== true,
|
|
268
290
|
close,
|
|
269
291
|
})
|
|
270
292
|
const {
|
|
@@ -277,6 +299,7 @@ const {
|
|
|
277
299
|
draftProposal,
|
|
278
300
|
rejectArmed,
|
|
279
301
|
canRequestChanges,
|
|
302
|
+
canEditProposal,
|
|
280
303
|
quorum: gateQuorum,
|
|
281
304
|
viewerHasApproved,
|
|
282
305
|
approvalWouldClearGate,
|
|
@@ -487,6 +510,9 @@ async function copyOutput() {
|
|
|
487
510
|
</div>
|
|
488
511
|
</header>
|
|
489
512
|
|
|
513
|
+
<!-- Whether the whole-run fetch behind this reader's prose has landed. -->
|
|
514
|
+
<RunDetailLoadState :instance-id="ctx?.instanceId ?? null" />
|
|
515
|
+
|
|
490
516
|
<div ref="scrollEl" class="flex-1 overflow-auto px-6 py-6" @scroll="onScroll">
|
|
491
517
|
<div class="mx-auto max-w-3xl space-y-5">
|
|
492
518
|
<!-- metadata card (always shown) -->
|
|
@@ -974,7 +1000,7 @@ async function copyOutput() {
|
|
|
974
1000
|
size="sm"
|
|
975
1001
|
icon="i-lucide-pencil"
|
|
976
1002
|
block
|
|
977
|
-
:disabled="rejectArmed || submitting || !!gateRefusal"
|
|
1003
|
+
:disabled="rejectArmed || submitting || !!gateRefusal || !canEditProposal"
|
|
978
1004
|
@click="startEditing"
|
|
979
1005
|
>
|
|
980
1006
|
{{ t('panels.stepDetail.approveWithCorrections') }}
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import { computed, ref, watch } from 'vue'
|
|
24
24
|
import { useModalBehavior } from '@modular-vue/core'
|
|
25
25
|
import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
26
|
+
import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
|
|
26
27
|
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
27
28
|
import StepValidationReport from '~/components/panels/StepValidationReport.vue'
|
|
28
29
|
import StepReproductionReport from '~/components/panels/StepReproductionReport.vue'
|
|
@@ -134,6 +135,22 @@ const activeStep = computed(() => {
|
|
|
134
135
|
return execution.getInstance(view.instanceId)?.steps[view.stepIndex] ?? null
|
|
135
136
|
})
|
|
136
137
|
const effortReport = computed(() => activeStep.value?.effortReport ?? null)
|
|
138
|
+
/**
|
|
139
|
+
* The run this window is about, and the fetch that makes it WHOLE. The board snapshot carries a
|
|
140
|
+
* lean projection (`projectExecutionForBoard`) whose steps withhold their captured prose, which is
|
|
141
|
+
* exactly what the windows mounted in this slot render. Asking here rather than in each window is
|
|
142
|
+
* the same reasoning as the trailing sections above: the host mounts exactly one window, so no
|
|
143
|
+
* window can forget to ask, and a run already held whole costs nothing (`ensureFull` returns).
|
|
144
|
+
*/
|
|
145
|
+
const activeInstanceId = computed(() => ui.resultView?.instanceId ?? null)
|
|
146
|
+
// Watched through `fullFetchKey`, not the id: a full refresh lands a lean projection over an open
|
|
147
|
+
// run, and at a newer revision the store cannot carry the cached prose forward, so the window has
|
|
148
|
+
// to ask again rather than render the withheld fields as an absence.
|
|
149
|
+
watch(
|
|
150
|
+
() => execution.fullFetchKey(activeInstanceId.value),
|
|
151
|
+
() => void execution.ensureFull(activeInstanceId.value),
|
|
152
|
+
{ immediate: true },
|
|
153
|
+
)
|
|
137
154
|
// The step's PRE-PR VALIDATION report — the second universal trailing section, resolved the same
|
|
138
155
|
// way and for the same reason: every window whose step ran a coding job can show whether the
|
|
139
156
|
// checkout actually passed the service's checks before the PR opened (and, on a red run, exactly
|
|
@@ -291,6 +308,8 @@ const panelClass = computed(() => [
|
|
|
291
308
|
<UIcon name="i-lucide-x" class="h-4 w-4" />
|
|
292
309
|
</button>
|
|
293
310
|
</header>
|
|
311
|
+
<!-- Whether the whole-run fetch behind this window's prose has landed. -->
|
|
312
|
+
<RunDetailLoadState :instance-id="activeInstanceId" />
|
|
294
313
|
<!-- The window body. -->
|
|
295
314
|
<slot />
|
|
296
315
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The one-line state of the WHOLE-RUN fetch a step-detail surface depends on.
|
|
4
|
+
*
|
|
5
|
+
* The board snapshot carries a lean projection of every run: each step's captured prose is
|
|
6
|
+
* WITHHELD, not absent (`projectExecutionForBoard`). A window that renders that prose asks the
|
|
7
|
+
* store for the whole run on open, and until that read lands (or when it fails) the surface has
|
|
8
|
+
* nothing to say about the prose. Both states have to be visible, because an empty reader and a
|
|
9
|
+
* failed fetch look identical and only one of them means "this step said nothing".
|
|
10
|
+
*
|
|
11
|
+
* Renders nothing for a run the store already holds whole, which is every run a live `execution`
|
|
12
|
+
* event delivered and every run already fetched once.
|
|
13
|
+
*/
|
|
14
|
+
import { computed } from 'vue'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{ instanceId: string | null }>()
|
|
17
|
+
|
|
18
|
+
const execution = useExecutionStore()
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
|
|
21
|
+
const loading = computed(() => execution.isFullPending(props.instanceId))
|
|
22
|
+
const error = computed(() => execution.fullError(props.instanceId))
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<div
|
|
27
|
+
v-if="loading || error"
|
|
28
|
+
class="flex items-center gap-2 border-b border-slate-800 px-4 py-2 text-[11px]"
|
|
29
|
+
:class="error ? 'text-rose-300' : 'text-slate-400'"
|
|
30
|
+
data-testid="run-detail-load-state"
|
|
31
|
+
>
|
|
32
|
+
<UIcon
|
|
33
|
+
:name="error ? 'i-lucide-triangle-alert' : 'i-lucide-loader-circle'"
|
|
34
|
+
class="h-3.5 w-3.5 shrink-0"
|
|
35
|
+
:class="error ? '' : 'animate-spin'"
|
|
36
|
+
/>
|
|
37
|
+
<span>{{
|
|
38
|
+
error ? t('panels.runDetail.loadFailed', { reason: error }) : t('panels.runDetail.loading')
|
|
39
|
+
}}</span>
|
|
40
|
+
</div>
|
|
41
|
+
</template>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { isDryRun } from '@cat-factory/contracts'
|
|
2
|
+
import { isDryRun, stepHasOutput } from '@cat-factory/contracts'
|
|
3
3
|
import type { Block } from '~/types/domain'
|
|
4
4
|
import { agentKindMeta } from '~/utils/catalog'
|
|
5
5
|
import {
|
|
@@ -390,7 +390,7 @@ async function mergePr() {
|
|
|
390
390
|
class="flex min-w-0 cursor-pointer items-center gap-2 text-start transition hover:text-white"
|
|
391
391
|
data-testid="run-step-open"
|
|
392
392
|
:title="
|
|
393
|
-
s
|
|
393
|
+
stepHasOutput(s)
|
|
394
394
|
? t('inspector.execution.viewDetailsOutput')
|
|
395
395
|
: t('inspector.execution.viewDetails')
|
|
396
396
|
"
|
|
@@ -412,7 +412,7 @@ async function mergePr() {
|
|
|
412
412
|
{{ t('inspector.execution.companion') }}
|
|
413
413
|
</span>
|
|
414
414
|
<UIcon
|
|
415
|
-
:name="s
|
|
415
|
+
:name="stepHasOutput(s) ? 'i-lucide-book-open-text' : 'i-lucide-info'"
|
|
416
416
|
class="h-3.5 w-3.5 shrink-0 text-slate-500"
|
|
417
417
|
/>
|
|
418
418
|
</button>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { AgentState, ExecutionInstance } from '~/types/domain'
|
|
3
3
|
import type { PipelineStep } from '~/types/execution'
|
|
4
|
+
import { stepHasOutput } from '@cat-factory/contracts'
|
|
4
5
|
import { agentKindMeta, FOLLOW_UP_COMPANION_META, FORK_DECISION_META } from '~/utils/catalog'
|
|
5
6
|
import {
|
|
6
7
|
subtaskIconClass,
|
|
@@ -341,7 +342,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
341
342
|
data-testid="pipeline-step"
|
|
342
343
|
:data-step-kind="s.agentKind"
|
|
343
344
|
:title="
|
|
344
|
-
s
|
|
345
|
+
stepHasOutput(s)
|
|
345
346
|
? t('pipeline.progress.viewDetailsOutput')
|
|
346
347
|
: t('pipeline.progress.viewDetails')
|
|
347
348
|
"
|
|
@@ -441,7 +442,7 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
441
442
|
</template>
|
|
442
443
|
|
|
443
444
|
<UIcon
|
|
444
|
-
:name="s
|
|
445
|
+
:name="stepHasOutput(s) ? 'i-lucide-book-open-text' : 'i-lucide-info'"
|
|
445
446
|
class="h-4 w-4 shrink-0 text-slate-500 transition-colors group-hover:text-indigo-300"
|
|
446
447
|
/>
|
|
447
448
|
</div>
|
|
@@ -555,7 +556,10 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
555
556
|
|
|
556
557
|
<!-- A one-line hint that the agent produced prose; the full output (and
|
|
557
558
|
all step metadata) lives in the step-detail overlay opened by click. -->
|
|
558
|
-
<p
|
|
559
|
+
<p
|
|
560
|
+
v-if="stepHasOutput(s)"
|
|
561
|
+
class="mt-2 flex items-center gap-1 text-[11px] text-slate-500"
|
|
562
|
+
>
|
|
559
563
|
<UIcon name="i-lucide-book-open-text" class="h-3 w-3 shrink-0" />
|
|
560
564
|
{{ t('pipeline.progress.clickToRead') }}
|
|
561
565
|
</p>
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
cancelExecutionContract,
|
|
4
4
|
exportExecutionLlmMetricsContract,
|
|
5
5
|
getExecutionAgentContextContract,
|
|
6
|
+
getExecutionContract,
|
|
6
7
|
getExecutionLlmMetricsContract,
|
|
7
8
|
getExecutionSearchQueriesContract,
|
|
8
9
|
getExecutionToolCallFailuresContract,
|
|
@@ -42,6 +43,17 @@ export function executionApi({ send, sendWith, ws, pwHeaders }: ApiContext) {
|
|
|
42
43
|
body,
|
|
43
44
|
}),
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* One run, WHOLE. The board snapshot serves a lean projection that withholds each step's
|
|
48
|
+
* captured prose, so a step-detail overlay fetches the run it is about through here before
|
|
49
|
+
* rendering. See `projectExecutionForBoard`.
|
|
50
|
+
*/
|
|
51
|
+
getExecution: (workspaceId: string, executionId: string) =>
|
|
52
|
+
send(getExecutionContract, {
|
|
53
|
+
pathPrefix: ws(workspaceId),
|
|
54
|
+
pathParams: { executionId },
|
|
55
|
+
}),
|
|
56
|
+
|
|
45
57
|
/**
|
|
46
58
|
* Start ONE agent kind against a block — a run with no pipeline behind it (the service
|
|
47
59
|
* frame's "Map service" action, the environment wizard's deep analysis). Gated on the
|