@cat-factory/app 0.20.0 → 0.22.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.
@@ -4,7 +4,9 @@ import { Background } from '@vue-flow/background'
4
4
  import { Controls } from '@vue-flow/controls'
5
5
  import { MiniMap } from '@vue-flow/minimap'
6
6
  import BlockNode from './nodes/BlockNode.vue'
7
+ import EpicNode from './nodes/EpicNode.vue'
7
8
  import TaskDependencyEdges from './TaskDependencyEdges.vue'
9
+ import DependencyConnectOverlay from './DependencyConnectOverlay.vue'
8
10
  import { STATUS_META } from '~/utils/catalog'
9
11
  import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
10
12
  import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
@@ -41,15 +43,24 @@ function frameExpanded(id: string) {
41
43
  return ui.isFrameExpanded(id) && ui.lod !== 'far'
42
44
  }
43
45
 
44
- const nodes = computed(() =>
45
- board.frames.map((b) => ({
46
+ const nodes = computed(() => [
47
+ ...board.frames.map((b) => ({
46
48
  id: b.id,
47
49
  type: 'block',
48
50
  position: b.position,
49
51
  draggable: !frameExpanded(b.id),
50
52
  data: {},
51
53
  })),
52
- )
54
+ // Epics are top-level grouping nodes (non-structural), drawn alongside frames and
55
+ // linked to their member tasks by the dependency-edge overlay.
56
+ ...board.epics.map((b) => ({
57
+ id: b.id,
58
+ type: 'epic',
59
+ position: b.position,
60
+ draggable: true,
61
+ data: {},
62
+ })),
63
+ ])
53
64
 
54
65
  onNodeDragStop(({ node }) => {
55
66
  board.moveBlock(node.id, { x: node.position.x, y: node.position.y })
@@ -149,6 +160,10 @@ async function onDrop(event: DragEvent) {
149
160
  <template #node-block="props">
150
161
  <BlockNode :id="props.id" />
151
162
  </template>
163
+
164
+ <template #node-epic="props">
165
+ <EpicNode :id="props.id" />
166
+ </template>
152
167
  </VueFlow>
153
168
 
154
169
  <!-- An empty board reads as broken; invite the user to add a service. The
@@ -183,5 +198,8 @@ async function onDrop(event: DragEvent) {
183
198
 
184
199
  <!-- task dependency arrows, overlaid in screen space -->
185
200
  <TaskDependencyEdges />
201
+
202
+ <!-- live preview line while drag-to-connecting a dependency -->
203
+ <DependencyConnectOverlay />
186
204
  </div>
187
205
  </template>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { useDependencyConnect } from '~/composables/useDependencyConnect'
3
+
4
+ // Draws the live preview line while a dependency drag-to-connect is in progress. Mounted
5
+ // once on the board; reads the shared connect state. Fixed, full-viewport, click-through.
6
+ const { connecting } = useDependencyConnect()
7
+ </script>
8
+
9
+ <template>
10
+ <svg
11
+ v-if="connecting"
12
+ class="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
13
+ >
14
+ <line
15
+ :x1="connecting.x1"
16
+ :y1="connecting.y1"
17
+ :x2="connecting.x2"
18
+ :y2="connecting.y2"
19
+ stroke="#f59e0b"
20
+ :stroke-width="2"
21
+ stroke-dasharray="5 4"
22
+ stroke-opacity="0.9"
23
+ />
24
+ </svg>
25
+ </template>
@@ -15,6 +15,9 @@ const svg = ref<SVGSVGElement | null>(null)
15
15
 
16
16
  type Seg = { id: string; x1: number; y1: number; x2: number; y2: number; done: boolean }
17
17
  const segments = ref<Seg[]>([])
18
+ // Epic→member membership links (distinct style from dependency edges).
19
+ type MemberSeg = { id: string; x1: number; y1: number; x2: number; y2: number }
20
+ const memberSegments = ref<MemberSeg[]>([])
18
21
 
19
22
  // task → its dependencies, both ends being tasks
20
23
  const taskDeps = computed(() => {
@@ -29,6 +32,17 @@ const taskDeps = computed(() => {
29
32
  return out
30
33
  })
31
34
 
35
+ // epic → each of its member tasks (membership, drawn as a soft violet link).
36
+ const epicLinks = computed(() => {
37
+ const out: { id: string; source: string; target: string }[] = []
38
+ for (const e of board.epics) {
39
+ for (const m of board.epicMembers(e.id)) {
40
+ out.push({ id: `${e.id}__${m.id}`, source: e.id, target: m.id })
41
+ }
42
+ }
43
+ return out
44
+ })
45
+
32
46
  /** Resolve a task's anchor: walk up task → module → service to the first card
33
47
  * that's actually rendered (a container may be collapsed). */
34
48
  function anchorEl(taskId: string): HTMLElement | null {
@@ -81,6 +95,23 @@ function recompute() {
81
95
  })
82
96
  }
83
97
  segments.value = next
98
+
99
+ const members: MemberSeg[] = []
100
+ for (const link of epicLinks.value) {
101
+ const a = anchorEl(link.source)
102
+ const b = anchorEl(link.target)
103
+ if (!a || !b || a === b) continue
104
+ const ra = a.getBoundingClientRect()
105
+ const rb = b.getBoundingClientRect()
106
+ const ax = ra.left + ra.width / 2 - origin.left
107
+ const ay = ra.top + ra.height / 2 - origin.top
108
+ const bx = rb.left + rb.width / 2 - origin.left
109
+ const by = rb.top + rb.height / 2 - origin.top
110
+ const start = border(ax, ay, ra.width / 2, ra.height / 2, bx, by)
111
+ const end = border(bx, by, rb.width / 2, rb.height / 2, ax, ay)
112
+ members.push({ id: link.id, x1: start.x, y1: start.y, x2: end.x, y2: end.y })
113
+ }
114
+ memberSegments.value = members
84
115
  }
85
116
 
86
117
  const { pause, resume } = useRafFn(recompute, { immediate: false })
@@ -115,6 +146,20 @@ onBeforeUnmount(pause)
115
146
  </marker>
116
147
  </defs>
117
148
 
149
+ <!-- epic → member membership links (soft violet, no arrowhead) -->
150
+ <line
151
+ v-for="s in memberSegments"
152
+ :key="s.id"
153
+ :x1="s.x1"
154
+ :y1="s.y1"
155
+ :x2="s.x2"
156
+ :y2="s.y2"
157
+ stroke="#8b5cf6"
158
+ :stroke-width="1.5"
159
+ stroke-dasharray="2 5"
160
+ :stroke-opacity="0.5"
161
+ />
162
+
118
163
  <line
119
164
  v-for="s in segments"
120
165
  :key="s.id"
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { Block } from '~/types/domain'
4
+
5
+ // Vue Flow passes the node's `id` as a prop. An epic is a NON-structural grouping node:
6
+ // it has no children of its own (tasks join it via their `epicId`), so it renders as a
7
+ // compact card showing the epic title + a roll-up of its member tasks. The dependency-edge
8
+ // overlay draws the links from this card to each member (anchored by `data-block-id`).
9
+ const props = defineProps<{ id: string }>()
10
+
11
+ const board = useBoardStore()
12
+ const ui = useUiStore()
13
+
14
+ const block = computed<Block | undefined>(() => board.getBlock(props.id))
15
+ const members = computed(() => board.epicMembers(props.id))
16
+ const total = computed(() => members.value.length)
17
+ const done = computed(() => members.value.filter((m) => m.status === 'done').length)
18
+ const active = computed(() =>
19
+ members.value.filter((m) => m.status === 'in_progress' || m.status === 'pr_ready').length,
20
+ )
21
+ const selected = computed(() => ui.selectedBlockId === props.id)
22
+ </script>
23
+
24
+ <template>
25
+ <div
26
+ v-if="block"
27
+ :data-block-id="block.id"
28
+ class="w-56 cursor-pointer rounded-lg border bg-slate-900/90 px-3 py-2 shadow-lg backdrop-blur transition-colors"
29
+ :class="
30
+ selected ? 'border-violet-400 ring-1 ring-violet-400/50' : 'border-violet-500/40'
31
+ "
32
+ @click="ui.select(block.id)"
33
+ >
34
+ <div class="flex items-center gap-1.5">
35
+ <UIcon name="i-lucide-layers" class="h-3.5 w-3.5 shrink-0 text-violet-400" />
36
+ <span class="text-[10px] font-semibold uppercase tracking-wide text-violet-300">Epic</span>
37
+ <span class="ml-auto text-[10px] text-slate-400">{{ done }}/{{ total }}</span>
38
+ </div>
39
+ <div class="mt-1 truncate text-sm font-medium text-slate-100" :title="block.title">
40
+ {{ block.title }}
41
+ </div>
42
+ <div class="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-slate-700/60">
43
+ <div
44
+ class="h-full rounded-full bg-violet-500"
45
+ :style="{ width: total ? `${Math.round((done / total) * 100)}%` : '0%' }"
46
+ />
47
+ </div>
48
+ <div v-if="active" class="mt-1 text-[10px] text-slate-400">{{ active }} active</div>
49
+ <div v-else-if="total === 0" class="mt-1 text-[10px] text-slate-500">No tasks yet</div>
50
+ </div>
51
+ </template>
@@ -18,6 +18,10 @@ const task = computed<Block | undefined>(() => board.getBlock(props.taskId))
18
18
  const statusMeta = computed(() => (task.value ? STATUS_META[task.value.status] : null))
19
19
  const selected = computed(() => ui.selectedBlockId === props.taskId)
20
20
 
21
+ // Drag-to-connect: dragging from this card's handle onto another task makes THAT task
22
+ // depend on this one (this is the prerequisite). The composable tracks the gesture.
23
+ const { start: startConnect } = useDependencyConnect()
24
+
21
25
  // ---- dependencies (gate execution order; may point across frames) ----------
22
26
  const deps = computed(() =>
23
27
  (task.value?.dependsOn ?? []).map((id) => board.getBlock(id)).filter((b): b is Block => !!b),
@@ -196,6 +200,16 @@ function selectTask() {
196
200
  :title="schedule.enabled ? 'Recurring pipeline' : 'Recurring pipeline (paused)'"
197
201
  />
198
202
  <span class="truncate text-[11px] font-semibold text-slate-100">{{ task.title }}</span>
203
+ <!-- drag-to-connect handle: drag onto another task to make it depend on this one -->
204
+ <button
205
+ type="button"
206
+ class="nodrag ml-1 shrink-0 cursor-crosshair rounded-full p-0.5 text-slate-500 hover:bg-slate-800 hover:text-amber-400"
207
+ title="Drag onto another task to make it depend on this one"
208
+ @pointerdown.stop="startConnect(task.id, $event)"
209
+ @click.stop
210
+ >
211
+ <UIcon name="i-lucide-spline" class="h-3 w-3" />
212
+ </button>
199
213
  <span
200
214
  class="ml-auto shrink-0 text-[9px] uppercase tracking-wide"
201
215
  :class="
@@ -0,0 +1,327 @@
1
+ <script setup lang="ts">
2
+ // Human-testing gate window — the dedicated surface for a `human-test` step (opened via the
3
+ // universal result-view host, the same seam the tester / requirements review use). It reads
4
+ // the gate's live state straight off the execution step (`step.humanTest`, pushed over the
5
+ // stream), surfaces the ephemeral environment URL, and drives the human actions: confirm
6
+ // (pass + tear down + advance), request a fix from findings (the Tester's fixer), pull latest
7
+ // main into the branch + redeploy (conflict → conflict-resolver), recreate, or destroy the env.
8
+ import type { HumanTestEnvironmentStatus, HumanTestStepState } from '~/types/execution'
9
+ import StepRunMeta from '~/components/panels/StepRunMeta.vue'
10
+
11
+ const board = useBoardStore()
12
+ const execution = useExecutionStore()
13
+ const humanTest = useHumanTestStore()
14
+
15
+ // Shared seam contract (open/blockId/close + Escape). No `onOpen` loader: the gate state
16
+ // rides on the execution step, pushed over the stream.
17
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('human-test')
18
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
19
+
20
+ const instance = computed(() =>
21
+ instanceId.value === null ? null : (execution.getInstance(instanceId.value) ?? null),
22
+ )
23
+ const step = computed(() => {
24
+ if (instance.value === null || stepIndex.value === null) return null
25
+ return instance.value.steps[stepIndex.value] ?? null
26
+ })
27
+ const ht = computed<HumanTestStepState | null>(() => step.value?.humanTest ?? null)
28
+ const env = computed(() => ht.value?.environment ?? null)
29
+ const phase = computed(() => ht.value?.phase ?? null)
30
+ const busy = computed(() => (blockId.value ? humanTest.isBusy(blockId.value) : false))
31
+
32
+ /** Whether the human can act right now (parked awaiting their input, not mid-helper/provision). */
33
+ const awaitingHuman = computed(() => phase.value === 'awaiting_human')
34
+ const working = computed(
35
+ () =>
36
+ phase.value === 'provisioning' ||
37
+ phase.value === 'fixing' ||
38
+ phase.value === 'resolving_conflicts',
39
+ )
40
+
41
+ const ENV_STATUS_META: Record<HumanTestEnvironmentStatus, { label: string; color: string }> = {
42
+ provisioning: { label: 'Provisioning…', color: 'text-amber-300' },
43
+ ready: { label: 'Ready', color: 'text-emerald-300' },
44
+ failed: { label: 'Failed', color: 'text-rose-300' },
45
+ expired: { label: 'Expired', color: 'text-slate-400' },
46
+ tearing_down: { label: 'Tearing down…', color: 'text-slate-400' },
47
+ torn_down: { label: 'Destroyed', color: 'text-slate-400' },
48
+ }
49
+
50
+ const PHASE_LABEL: Record<NonNullable<HumanTestStepState['phase']>, string> = {
51
+ provisioning: 'Provisioning environment…',
52
+ awaiting_human: 'Awaiting your validation',
53
+ fixing: 'Fixer is addressing your findings…',
54
+ resolving_conflicts: 'Resolving conflicts with main…',
55
+ passed: 'Passed',
56
+ }
57
+
58
+ const findings = ref('')
59
+ const showFindings = ref(false)
60
+
61
+ async function confirm() {
62
+ if (!blockId.value) return
63
+ await humanTest.confirm(blockId.value)
64
+ close()
65
+ }
66
+ async function submitFix() {
67
+ if (!blockId.value || !findings.value.trim()) return
68
+ await humanTest.requestFix(blockId.value, findings.value.trim())
69
+ findings.value = ''
70
+ showFindings.value = false
71
+ }
72
+ async function pullMain() {
73
+ if (!blockId.value) return
74
+ await humanTest.pullMain(blockId.value)
75
+ }
76
+ async function recreate() {
77
+ if (!blockId.value) return
78
+ await humanTest.recreateEnv(blockId.value)
79
+ }
80
+ async function destroy() {
81
+ if (!blockId.value) return
82
+ await humanTest.destroyEnv(blockId.value)
83
+ }
84
+
85
+ /** Env actions need a provider (an env is/was present, or it's provisioning) — disabled in degraded mode. */
86
+ const envActionsEnabled = computed(() => env.value !== null && env.value !== undefined)
87
+
88
+ // The env-management actions are only valid in specific phases; mirror the backend's preconditions
89
+ // so the UI never dispatches an action that would 409 ("No human-test gate is currently awaiting
90
+ // input"). Recreate / pull-main route through `findParked` (parked awaiting the human); destroy
91
+ // routes through `findActive`, which also tolerates an in-flight `provisioning` env so a human can
92
+ // cancel a slow/stuck provision.
93
+ const canManageEnv = computed(() => awaitingHuman.value)
94
+ const canDestroy = computed(
95
+ () => envActionsEnabled.value && (awaitingHuman.value || phase.value === 'provisioning'),
96
+ )
97
+ </script>
98
+
99
+ <template>
100
+ <Teleport to="body">
101
+ <div
102
+ v-if="open"
103
+ class="fixed inset-0 z-50 flex items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
104
+ @click.self="close"
105
+ >
106
+ <div
107
+ class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
108
+ >
109
+ <!-- Header -->
110
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
111
+ <span
112
+ class="flex h-8 w-8 items-center justify-center rounded-lg bg-amber-500/15 text-amber-300"
113
+ >
114
+ <UIcon name="i-lucide-user-check" class="h-4 w-4" />
115
+ </span>
116
+ <div class="min-w-0 flex-1">
117
+ <h2 class="truncate text-sm font-semibold text-slate-100">
118
+ Human testing{{ block ? ` — ${block.title}` : '' }}
119
+ </h2>
120
+ <p class="truncate text-[11px] text-slate-400">
121
+ {{ phase ? PHASE_LABEL[phase] : 'Validate the change in a live environment' }}
122
+ </p>
123
+ </div>
124
+ <button
125
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
126
+ @click="close"
127
+ >
128
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
129
+ </button>
130
+ </header>
131
+
132
+ <div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-5 py-4">
133
+ <div
134
+ v-if="!ht"
135
+ class="flex flex-col items-center justify-center gap-2 py-10 text-center text-slate-400"
136
+ >
137
+ <UIcon name="i-lucide-user-check" class="h-8 w-8 opacity-40" />
138
+ <p class="text-sm">This step hasn't started yet.</p>
139
+ </div>
140
+
141
+ <template v-else>
142
+ <!-- Environment -->
143
+ <section class="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
144
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
145
+ Ephemeral environment
146
+ </h3>
147
+ <div v-if="env" class="space-y-2">
148
+ <div class="flex items-center gap-2 text-[13px]">
149
+ <UIcon
150
+ name="i-lucide-circle-dot"
151
+ class="h-3.5 w-3.5"
152
+ :class="ENV_STATUS_META[env.status].color"
153
+ />
154
+ <span :class="ENV_STATUS_META[env.status].color">{{
155
+ ENV_STATUS_META[env.status].label
156
+ }}</span>
157
+ </div>
158
+ <a
159
+ v-if="env.url"
160
+ :href="env.url"
161
+ target="_blank"
162
+ rel="noopener"
163
+ class="inline-flex items-center gap-1.5 break-all text-[13px] text-sky-300 hover:underline"
164
+ >
165
+ <UIcon name="i-lucide-external-link" class="h-3.5 w-3.5 shrink-0" />
166
+ {{ env.url }}
167
+ </a>
168
+ <p v-else class="text-[12px] italic text-slate-500">No URL yet.</p>
169
+ <p v-if="env.expiresAt" class="text-[11px] text-slate-500">
170
+ Expires {{ new Date(env.expiresAt).toLocaleString() }}
171
+ </p>
172
+ </div>
173
+ <p v-else class="text-[12px] text-amber-300/90">
174
+ {{ ht.degradedReason ?? 'No live environment.' }}
175
+ </p>
176
+ <p v-if="env && ht.degradedReason" class="mt-2 text-[12px] text-amber-300/90">
177
+ {{ ht.degradedReason }}
178
+ </p>
179
+
180
+ <!-- Env management -->
181
+ <div class="mt-3 flex flex-wrap gap-2">
182
+ <UButton
183
+ size="xs"
184
+ variant="soft"
185
+ color="neutral"
186
+ icon="i-lucide-refresh-cw"
187
+ :loading="busy"
188
+ :disabled="busy || !canManageEnv"
189
+ @click="recreate"
190
+ >
191
+ Recreate
192
+ </UButton>
193
+ <UButton
194
+ size="xs"
195
+ variant="soft"
196
+ color="neutral"
197
+ icon="i-lucide-trash-2"
198
+ :disabled="busy || !canDestroy"
199
+ @click="destroy"
200
+ >
201
+ Destroy
202
+ </UButton>
203
+ <UButton
204
+ size="xs"
205
+ variant="soft"
206
+ color="neutral"
207
+ icon="i-lucide-git-merge"
208
+ :loading="busy"
209
+ :disabled="busy || !canManageEnv"
210
+ @click="pullMain"
211
+ >
212
+ Pull main + redeploy
213
+ </UButton>
214
+ </div>
215
+ </section>
216
+
217
+ <!-- Working state -->
218
+ <p
219
+ v-if="working"
220
+ class="flex items-center gap-2 rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2 text-[12px] text-slate-300"
221
+ >
222
+ <UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin text-amber-300" />
223
+ {{ phase ? PHASE_LABEL[phase] : '' }}
224
+ </p>
225
+
226
+ <!-- Findings / fix -->
227
+ <section
228
+ v-if="awaitingHuman"
229
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
230
+ >
231
+ <div class="flex items-center justify-between">
232
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-500">
233
+ Found a problem?
234
+ </h3>
235
+ <button
236
+ class="text-[12px] text-slate-400 hover:text-slate-200"
237
+ @click="showFindings = !showFindings"
238
+ >
239
+ {{ showFindings ? 'Cancel' : 'Request a fix' }}
240
+ </button>
241
+ </div>
242
+ <div v-if="showFindings" class="mt-2 space-y-2">
243
+ <textarea
244
+ v-model="findings"
245
+ rows="4"
246
+ placeholder="Describe what went wrong — the Fixer agent gets this as context, then the environment is rebuilt for re-testing."
247
+ class="w-full rounded-md border border-slate-700 bg-slate-950 px-3 py-2 text-[13px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500 focus:outline-none"
248
+ />
249
+ <UButton
250
+ size="sm"
251
+ color="warning"
252
+ icon="i-lucide-wrench"
253
+ :loading="busy"
254
+ :disabled="busy || !findings.trim()"
255
+ @click="submitFix"
256
+ >
257
+ Send to Fixer
258
+ </UButton>
259
+ </div>
260
+ </section>
261
+
262
+ <!-- Rounds history -->
263
+ <section
264
+ v-if="ht.rounds && ht.rounds.length"
265
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
266
+ >
267
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
268
+ History ({{ ht.attempts }} round{{ ht.attempts === 1 ? '' : 's' }})
269
+ </h3>
270
+ <ol class="space-y-2">
271
+ <li v-for="(r, i) in ht.rounds" :key="i" class="flex items-start gap-2 text-[12px]">
272
+ <UIcon
273
+ :name="r.kind === 'fix' ? 'i-lucide-wrench' : 'i-lucide-git-merge'"
274
+ class="mt-0.5 h-3.5 w-3.5 shrink-0 text-slate-400"
275
+ />
276
+ <div class="min-w-0 flex-1">
277
+ <span class="text-slate-200">{{
278
+ r.kind === 'fix' ? 'Fix requested' : 'Pulled main'
279
+ }}</span>
280
+ <span
281
+ class="ml-1.5 rounded px-1 text-[10px] uppercase"
282
+ :class="
283
+ r.outcome === 'completed'
284
+ ? 'bg-emerald-500/15 text-emerald-300'
285
+ : r.outcome === 'failed'
286
+ ? 'bg-rose-500/15 text-rose-300'
287
+ : 'bg-slate-500/15 text-slate-300'
288
+ "
289
+ >
290
+ {{ r.outcome ?? 'in progress' }}
291
+ </span>
292
+ <p v-if="r.findings" class="leading-snug text-slate-400">{{ r.findings }}</p>
293
+ </div>
294
+ </li>
295
+ </ol>
296
+ </section>
297
+ </template>
298
+ </div>
299
+
300
+ <!-- Footer: the primary confirm action -->
301
+ <footer
302
+ v-if="ht"
303
+ class="flex items-center justify-between gap-3 border-t border-slate-800 px-5 py-3"
304
+ >
305
+ <StepRunMeta
306
+ v-if="step"
307
+ :step="step"
308
+ :instance-id="instanceId ?? undefined"
309
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
310
+ :total-steps="instance?.steps.length"
311
+ :run-failed="instance?.status === 'failed'"
312
+ :failure-at="instance?.failure?.occurredAt"
313
+ />
314
+ <UButton
315
+ color="primary"
316
+ icon="i-lucide-circle-check"
317
+ :loading="busy"
318
+ :disabled="busy || !awaitingHuman"
319
+ @click="confirm"
320
+ >
321
+ Looks good — continue
322
+ </UButton>
323
+ </footer>
324
+ </div>
325
+ </div>
326
+ </Teleport>
327
+ </template>
@@ -33,6 +33,9 @@ const META: Record<Notification['type'], { icon: string; color: Accent; action:
33
33
  // with the iteration-cap prompt; requirements → the review window); "act" just marks it
34
34
  // read (the decision itself is resolved in that surface, not here).
35
35
  decision_required: { icon: 'i-lucide-circle-help', color: 'warning', action: 'Mark read' },
36
+ // Clicking the title opens the human-testing window for the task (see `reveal`); "act" just
37
+ // marks it read (the gate is resolved in that window — confirm / request a fix — not here).
38
+ human_test_ready: { icon: 'i-lucide-user-check', color: 'primary', action: 'Mark read' },
36
39
  }
37
40
 
38
41
  /** A notification the escalation sweep has flagged as overdue (waited past the threshold). */
@@ -77,9 +80,25 @@ function reveal(n: Notification) {
77
80
  if (n.type === 'requirement_review') ui.openRequirementReview(n.blockId)
78
81
  else if (n.type === 'clarity_review') ui.openClarityReview(n.blockId)
79
82
  else if (n.type === 'decision_required') revealDecision(n)
83
+ else if (n.type === 'human_test_ready') revealHumanTest(n)
80
84
  else ui.select(n.blockId)
81
85
  }
82
86
 
87
+ /**
88
+ * Open the human-testing window for a parked `human-test` gate: find the run's parked
89
+ * human-test step and open it through the universal step dispatch (its archetype declares
90
+ * the `human-test` result view). Falls back to focusing the block.
91
+ */
92
+ function revealHumanTest(n: Notification) {
93
+ const instance = n.executionId ? execution.getInstance(n.executionId) : undefined
94
+ const idx =
95
+ instance?.steps.findIndex(
96
+ (s) => s.agentKind === 'human-test' && s.state === 'waiting_decision',
97
+ ) ?? -1
98
+ if (instance && idx >= 0) ui.openStepDetail(instance.id, idx)
99
+ else if (n.blockId) ui.select(n.blockId)
100
+ }
101
+
83
102
  /**
84
103
  * Open the decision surface for a parked iteration-cap run: find the run's step that is
85
104
  * waiting on a human and open it through the universal step dispatch — which routes a
@@ -12,6 +12,7 @@ import TaskDependencies from '~/components/panels/inspector/TaskDependencies.vue
12
12
  import TaskStructure from '~/components/panels/inspector/TaskStructure.vue'
13
13
  import TaskRunSettings from '~/components/panels/inspector/TaskRunSettings.vue'
14
14
  import TaskExecution from '~/components/panels/inspector/TaskExecution.vue'
15
+ import EpicChildren from '~/components/panels/inspector/EpicChildren.vue'
15
16
  import RecurringScheduleSettings from '~/components/panels/inspector/RecurringScheduleSettings.vue'
16
17
  import AgentFailureCard from '~/components/board/AgentFailureCard.vue'
17
18
  import AgentStopButton from '~/components/board/AgentStopButton.vue'
@@ -51,6 +52,7 @@ const level = computed(() => block.value?.level ?? 'frame')
51
52
  const isFrame = computed(() => level.value === 'frame')
52
53
  const isContainer = computed(() => level.value === 'frame' || level.value === 'module')
53
54
  const isTask = computed(() => level.value === 'task')
55
+ const isEpic = computed(() => level.value === 'epic')
54
56
 
55
57
  const instance = computed(() => execution.getInstance(block.value?.executionId))
56
58
  const typeMeta = computed(() => (block.value ? blockTypeMeta(block.value.type) : null))
@@ -442,6 +444,9 @@ const showOriginalDescription = ref(false)
442
444
  <TaskExecution :block="block" />
443
445
  </template>
444
446
 
447
+ <!-- epic: the full tree of member tasks, grouped by service → module -->
448
+ <EpicChildren v-else-if="isEpic" :block="block" />
449
+
445
450
  <!-- actions -->
446
451
  <div class="flex items-center gap-2">
447
452
  <UDropdownMenu v-if="isTask" :items="runMenu">
@@ -14,6 +14,7 @@ import { computed, type Component } from 'vue'
14
14
  import RequirementsReviewWindow from '~/components/requirements/RequirementsReviewWindow.vue'
15
15
  import ClarityReviewWindow from '~/components/clarity/ClarityReviewWindow.vue'
16
16
  import TestReportWindow from '~/components/testing/TestReportWindow.vue'
17
+ import HumanTestWindow from '~/components/humanTest/HumanTestWindow.vue'
17
18
  import GateResultView from '~/components/gates/GateResultView.vue'
18
19
  import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
19
20
  import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
@@ -25,6 +26,8 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
25
26
  'requirements-review': RequirementsReviewWindow,
26
27
  'clarity-review': ClarityReviewWindow,
27
28
  tester: TestReportWindow,
29
+ // The human-testing gate: env URL + confirm / request-fix / pull-main / recreate / destroy.
30
+ 'human-test': HumanTestWindow,
28
31
  // Shared by both polling gates (`ci` + `conflicts`); the window branches on agentKind.
29
32
  gate: GateResultView,
30
33
  // Opened for any step that ran the consensus mechanism (routed in `ui.dispatchStepView`).