@cat-factory/app 0.136.1 → 0.137.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.
@@ -9,7 +9,7 @@
9
9
  import { computed } from 'vue'
10
10
  import type { MergeAxis, MergeDecision } from '@cat-factory/contracts'
11
11
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
12
- import StepRestartControl from '~/components/panels/StepRestartControl.vue'
12
+ import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
13
13
  import MarkdownProse from '~/components/common/MarkdownProse.vue'
14
14
 
15
15
  const board = useBoardStore()
@@ -17,9 +17,12 @@ const execution = useExecutionStore()
17
17
  const agents = useAgentsStore()
18
18
  const { t, n } = useI18n()
19
19
 
20
- // Shared seam contract (open/blockId/close + Escape). No loader: the verdict is read
21
- // straight off the execution step.
22
- const { open, blockId, instanceId, stepIndex, close } = useResultView('merger')
20
+ // Shared seam contract (open/blockId/close). No loader: the verdict is read straight off
21
+ // the execution step. `manageEscape: false` — `ResultWindowShell` owns Escape (and focus
22
+ // trap + scroll lock + stacking) via the shared overlay behaviour.
23
+ const { open, blockId, instanceId, stepIndex, close } = useResultView('merger', {
24
+ manageEscape: false,
25
+ })
23
26
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
24
27
 
25
28
  const instance = computed(() =>
@@ -119,151 +122,122 @@ const reasonText = computed(() => {
119
122
  </script>
120
123
 
121
124
  <template>
122
- <Teleport to="body">
123
- <div
124
- v-if="open"
125
- class="fixed inset-0 z-50 flex max-h-[100dvh] items-stretch justify-center bg-slate-950/70 backdrop-blur-sm"
126
- @click.self="close"
127
- >
128
- <div
129
- class="m-4 flex w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
130
- >
131
- <!-- Header -->
132
- <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
133
- <span
134
- class="flex h-8 w-8 items-center justify-center rounded-lg bg-lime-500/15 text-lime-300"
135
- >
136
- <UIcon :name="meta?.icon ?? 'i-lucide-git-pull-request'" class="h-4 w-4" />
137
- </span>
138
- <div class="min-w-0 flex-1">
139
- <h2 class="truncate text-sm font-semibold text-slate-100">{{ headerTitle }}</h2>
140
- <p class="truncate text-[11px] text-slate-400">
141
- {{ t('panels.mergerResult.description') }}
142
- </p>
143
- </div>
144
- <StepRestartControl
145
- :instance-id="instanceId"
146
- :step-index="stepIndex"
147
- @restarted="close"
148
- />
149
- <button
150
- class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
151
- @click="close"
125
+ <ResultWindowShell
126
+ :open="open"
127
+ :icon="meta?.icon ?? 'i-lucide-git-pull-request'"
128
+ icon-class="bg-lime-500/15 text-lime-300"
129
+ :title="headerTitle"
130
+ :subtitle="t('panels.mergerResult.description')"
131
+ :step-ref="{ instanceId, stepIndex }"
132
+ width="3xl"
133
+ @close="close"
134
+ >
135
+ <div class="flex min-h-0 flex-1">
136
+ <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
137
+ <template v-if="decision">
138
+ <!-- Decision banner: auto-merged (success) vs awaiting human review (warning). -->
139
+ <div
140
+ class="mb-4 flex items-start gap-3 rounded-lg border p-3"
141
+ :class="
142
+ merged
143
+ ? 'border-emerald-800/70 bg-emerald-500/10'
144
+ : 'border-amber-800/70 bg-amber-500/10'
145
+ "
146
+ data-testid="merger-decision"
147
+ :data-outcome="decision.outcome"
152
148
  >
153
- <UIcon name="i-lucide-x" class="h-4 w-4" />
154
- </button>
155
- </header>
156
-
157
- <div class="flex min-h-0 flex-1">
158
- <div class="min-w-0 flex-1 overflow-y-auto px-5 py-4">
159
- <template v-if="decision">
160
- <!-- Decision banner: auto-merged (success) vs awaiting human review (warning). -->
161
- <div
162
- class="mb-4 flex items-start gap-3 rounded-lg border p-3"
163
- :class="
164
- merged
165
- ? 'border-emerald-800/70 bg-emerald-500/10'
166
- : 'border-amber-800/70 bg-amber-500/10'
167
- "
168
- data-testid="merger-decision"
169
- :data-outcome="decision.outcome"
149
+ <UIcon
150
+ :name="merged ? 'i-lucide-git-merge' : 'i-lucide-user-round-check'"
151
+ class="mt-0.5 h-5 w-5 shrink-0"
152
+ :class="merged ? 'text-emerald-300' : 'text-amber-300'"
153
+ />
154
+ <div class="min-w-0">
155
+ <p
156
+ class="text-sm font-semibold"
157
+ :class="merged ? 'text-emerald-200' : 'text-amber-200'"
170
158
  >
171
- <UIcon
172
- :name="merged ? 'i-lucide-git-merge' : 'i-lucide-user-round-check'"
173
- class="mt-0.5 h-5 w-5 shrink-0"
174
- :class="merged ? 'text-emerald-300' : 'text-amber-300'"
175
- />
176
- <div class="min-w-0">
177
- <p
178
- class="text-sm font-semibold"
179
- :class="merged ? 'text-emerald-200' : 'text-amber-200'"
180
- >
181
- {{ outcomeText }}
182
- </p>
183
- <p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
184
- </div>
185
- </div>
159
+ {{ outcomeText }}
160
+ </p>
161
+ <p class="mt-0.5 text-[13px] leading-relaxed text-slate-300">{{ reasonText }}</p>
162
+ </div>
163
+ </div>
186
164
 
187
- <!-- Scores vs the resolved preset's ceilings. -->
188
- <template v-if="axes.length">
189
- <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
190
- {{ t('panels.mergerResult.scores') }}
191
- </h3>
192
- <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
193
- <div v-for="axis in axes" :key="axis.key" class="flex items-center gap-2">
194
- <span class="w-20 shrink-0 text-xs text-slate-400">{{ axis.label }}</span>
195
- <div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-800">
196
- <div
197
- class="h-full rounded-full"
198
- :class="exceeded.has(axis.key) ? 'bg-rose-500' : 'bg-emerald-500'"
199
- :style="{ width: `${Math.round(axis.score * 100)}%` }"
200
- />
201
- </div>
202
- <span
203
- class="w-11 shrink-0 text-end text-xs tabular-nums"
204
- :class="exceeded.has(axis.key) ? 'text-rose-300' : 'text-slate-300'"
205
- >
206
- {{ n(axis.score, { key: 'percent' }) }}
207
- </span>
208
- <span class="w-24 shrink-0 text-end text-[10px] tabular-nums text-slate-500">
209
- {{
210
- t('panels.mergerResult.ceiling', {
211
- value: n(axis.ceiling, { key: 'percent' }),
212
- })
213
- }}
214
- </span>
215
- </div>
165
+ <!-- Scores vs the resolved preset's ceilings. -->
166
+ <template v-if="axes.length">
167
+ <h3 class="mb-2 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
168
+ {{ t('panels.mergerResult.scores') }}
169
+ </h3>
170
+ <div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
171
+ <div v-for="axis in axes" :key="axis.key" class="flex items-center gap-2">
172
+ <span class="w-20 shrink-0 text-xs text-slate-400">{{ axis.label }}</span>
173
+ <div class="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-800">
174
+ <div
175
+ class="h-full rounded-full"
176
+ :class="exceeded.has(axis.key) ? 'bg-rose-500' : 'bg-emerald-500'"
177
+ :style="{ width: `${Math.round(axis.score * 100)}%` }"
178
+ />
216
179
  </div>
217
- </template>
218
-
219
- <!-- The agent's prose justification. -->
220
- <template v-if="decision.assessment?.rationale">
221
- <h3
222
- class="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
180
+ <span
181
+ class="w-11 shrink-0 text-end text-xs tabular-nums"
182
+ :class="exceeded.has(axis.key) ? 'text-rose-300' : 'text-slate-300'"
223
183
  >
224
- {{ t('panels.mergerResult.rationale') }}
225
- </h3>
226
- <MarkdownProse
227
- :text="decision.assessment.rationale"
228
- class="text-[13px] leading-relaxed text-slate-300"
229
- />
230
- </template>
231
- <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
232
- {{ t('panels.mergerResult.noAssessment') }}
233
- </p>
234
- </template>
184
+ {{ n(axis.score, { key: 'percent' }) }}
185
+ </span>
186
+ <span class="w-24 shrink-0 text-end text-[10px] tabular-nums text-slate-500">
187
+ {{
188
+ t('panels.mergerResult.ceiling', {
189
+ value: n(axis.ceiling, { key: 'percent' }),
190
+ })
191
+ }}
192
+ </span>
193
+ </div>
194
+ </div>
195
+ </template>
235
196
 
236
- <!-- Pre-structured runs kept only the raw prose output. -->
197
+ <!-- The agent's prose justification. -->
198
+ <template v-if="decision.assessment?.rationale">
199
+ <h3 class="mb-2 mt-4 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
200
+ {{ t('panels.mergerResult.rationale') }}
201
+ </h3>
237
202
  <MarkdownProse
238
- v-else-if="step?.output"
239
- :text="step.output"
203
+ :text="decision.assessment.rationale"
240
204
  class="text-[13px] leading-relaxed text-slate-300"
241
205
  />
242
- <div
243
- v-else
244
- class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
245
- >
246
- <UIcon name="i-lucide-git-pull-request" class="h-8 w-8 opacity-40" />
247
- <p class="text-sm">{{ t('panels.mergerResult.noResult') }}</p>
248
- </div>
249
- </div>
250
-
251
- <!-- Sidebar: shared run metadata. -->
252
- <aside
253
- class="hidden w-60 shrink-0 flex-col gap-4 border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
254
- >
255
- <StepRunMeta
256
- v-if="step"
257
- :step="step"
258
- :instance-id="instanceId ?? undefined"
259
- :step-number="stepIndex === null ? undefined : stepIndex + 1"
260
- :total-steps="instance?.steps.length"
261
- :run-failed="instance?.status === 'failed'"
262
- :failure-at="instance?.failure?.occurredAt"
263
- />
264
- </aside>
206
+ </template>
207
+ <p v-else class="text-[13px] italic leading-relaxed text-slate-500">
208
+ {{ t('panels.mergerResult.noAssessment') }}
209
+ </p>
210
+ </template>
211
+
212
+ <!-- Pre-structured runs kept only the raw prose output. -->
213
+ <MarkdownProse
214
+ v-else-if="step?.output"
215
+ :text="step.output"
216
+ class="text-[13px] leading-relaxed text-slate-300"
217
+ />
218
+ <div
219
+ v-else
220
+ class="flex h-full flex-col items-center justify-center gap-2 text-center text-slate-400"
221
+ >
222
+ <UIcon name="i-lucide-git-pull-request" class="h-8 w-8 opacity-40" />
223
+ <p class="text-sm">{{ t('panels.mergerResult.noResult') }}</p>
265
224
  </div>
266
225
  </div>
226
+
227
+ <!-- Sidebar: shared run metadata. -->
228
+ <aside
229
+ class="hidden w-60 shrink-0 flex-col gap-4 border-s border-slate-800 bg-slate-900/50 px-4 py-4 lg:flex"
230
+ >
231
+ <StepRunMeta
232
+ v-if="step"
233
+ :step="step"
234
+ :instance-id="instanceId ?? undefined"
235
+ :step-number="stepIndex === null ? undefined : stepIndex + 1"
236
+ :total-steps="instance?.steps.length"
237
+ :run-failed="instance?.status === 'failed'"
238
+ :failure-at="instance?.failure?.occurredAt"
239
+ />
240
+ </aside>
267
241
  </div>
268
- </Teleport>
242
+ </ResultWindowShell>
269
243
  </template>
@@ -0,0 +1,141 @@
1
+ <script setup lang="ts">
2
+ // Shared modal shell for the agent-run result windows (slice 5 of the modular-vue
3
+ // adoption — docs/initiatives/modular-vue-adoption.md; progress in
4
+ // docs/initiatives/modular-vue-slice5-progress.md).
5
+ //
6
+ // Every result window (the merger verdict, the tester report, the requirements-review
7
+ // loop, the gates, …) used to hand-roll the SAME modal chrome — `<Teleport>`, a
8
+ // backdrop, a bordered card, a header row with an icon/title/close — and, worse,
9
+ // re-implemented the modal *behaviour* inconsistently: only 2 of ~18 trapped focus, each
10
+ // registered its own global Escape listener, and every one hard-coded `z-50` with no
11
+ // stacking. This shell centralises the chrome AND delegates the behaviour to the upstream
12
+ // `useModalBehavior` (`@modular-vue/core`, the slice-5 overlay-host release): focus-trap
13
+ // + focus-return, body-scroll lock, and a shared overlay STACK so the top overlay closes
14
+ // first on Escape. A window becomes body-only markup wrapped in `<ResultWindowShell>`; it
15
+ // keeps its `useResultView` seam but passes `manageEscape: false` (the shell owns Escape).
16
+ //
17
+ // The pick-one SELECTION of which window is active stays exactly the slice-2
18
+ // `resolveComponentRegistry` in `StepResultViewHost.vue` — this shell only owns the
19
+ // per-window chrome + behaviour, so windows convert one at a time behind it.
20
+ import { computed } from 'vue'
21
+ import { useModalBehavior } from '@modular-vue/core'
22
+ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
23
+
24
+ /** A pipeline step reference — passed by step-result windows to surface the shared
25
+ * "restart from here" control. `StepRestartControl` self-hides for an off-path open
26
+ * (null ids), so a block-keyed window simply omits this prop. */
27
+ type StepRef = { instanceId: string | null; stepIndex: number | null }
28
+
29
+ const props = withDefaults(
30
+ defineProps<{
31
+ /** Whether the window is open — drives the modal behaviour's activation. */
32
+ open: boolean
33
+ /** Header icon (a `UIcon` name) + its badge colour classes. */
34
+ icon?: string
35
+ iconClass?: string
36
+ /** Header title (the accessible dialog name) + optional secondary line. */
37
+ title: string
38
+ subtitle?: string
39
+ /** Card width bucket + backdrop layout (the two pre-slice-5 chrome variants). */
40
+ width?: '3xl' | '4xl' | '5xl'
41
+ variant?: 'stretch' | 'centered'
42
+ /** Provide on step-result windows to show the shared restart control; omit on gates
43
+ * and block-keyed windows (no restart mid-gate / pre-run). */
44
+ stepRef?: StepRef
45
+ /** `data-testid` on the dialog root — pass a window's existing id to preserve e2e
46
+ * selectors; defaults to `result-window`. */
47
+ testid?: string
48
+ }>(),
49
+ {
50
+ icon: 'i-lucide-square',
51
+ iconClass: 'bg-slate-500/15 text-slate-300',
52
+ subtitle: undefined,
53
+ width: '3xl',
54
+ variant: 'stretch',
55
+ stepRef: undefined,
56
+ testid: undefined,
57
+ },
58
+ )
59
+
60
+ const emit = defineEmits<{ close: [] }>()
61
+ const { t } = useI18n()
62
+
63
+ function requestClose() {
64
+ emit('close')
65
+ }
66
+
67
+ // Managed modal behaviour (focus-trap + return, scroll lock, shared-stack Escape). The
68
+ // window unmounts on close, so deactivation + cleanup fire via `active` going false and
69
+ // unmount — no manual teardown here.
70
+ const { dialogRef } = useModalBehavior({
71
+ active: () => props.open,
72
+ onClose: requestClose,
73
+ })
74
+
75
+ const WIDTH: Record<'3xl' | '4xl' | '5xl', string> = {
76
+ '3xl': 'max-w-3xl',
77
+ '4xl': 'max-w-4xl',
78
+ '5xl': 'max-w-5xl',
79
+ }
80
+ const backdropClass = computed(() => [
81
+ 'fixed inset-0 z-50 flex max-h-[100dvh] justify-center bg-slate-950/70 backdrop-blur-sm',
82
+ props.variant === 'centered' ? 'items-center p-4' : 'items-stretch',
83
+ ])
84
+ const panelClass = computed(() => [
85
+ 'flex w-full flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl',
86
+ WIDTH[props.width],
87
+ props.variant === 'centered' ? 'max-h-[90dvh]' : 'm-4',
88
+ ])
89
+ </script>
90
+
91
+ <template>
92
+ <Teleport to="body">
93
+ <div
94
+ v-if="open"
95
+ :class="backdropClass"
96
+ data-testid="result-window-backdrop"
97
+ @click.self="requestClose"
98
+ >
99
+ <div
100
+ ref="dialogRef"
101
+ tabindex="-1"
102
+ :class="panelClass"
103
+ role="dialog"
104
+ aria-modal="true"
105
+ :aria-label="title"
106
+ :data-testid="testid ?? 'result-window'"
107
+ >
108
+ <header class="flex items-center gap-3 border-b border-slate-800 px-5 py-3">
109
+ <span
110
+ class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
111
+ :class="iconClass"
112
+ >
113
+ <UIcon :name="icon" class="h-4 w-4" />
114
+ </span>
115
+ <div class="min-w-0 flex-1">
116
+ <h2 class="truncate text-sm font-semibold text-slate-100">{{ title }}</h2>
117
+ <p v-if="subtitle" class="truncate text-[11px] text-slate-400">{{ subtitle }}</p>
118
+ </div>
119
+ <!-- Window-specific header content (status badges, counts). -->
120
+ <slot name="header-extras" />
121
+ <StepRestartControl
122
+ v-if="stepRef"
123
+ :instance-id="stepRef.instanceId"
124
+ :step-index="stepRef.stepIndex"
125
+ @restarted="requestClose"
126
+ />
127
+ <button
128
+ class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
129
+ data-testid="result-window-close"
130
+ :aria-label="t('common.close')"
131
+ @click="requestClose"
132
+ >
133
+ <UIcon name="i-lucide-x" class="h-4 w-4" />
134
+ </button>
135
+ </header>
136
+ <!-- The window body. -->
137
+ <slot />
138
+ </div>
139
+ </div>
140
+ </Teleport>
141
+ </template>
@@ -300,6 +300,8 @@ const ITEM_ICON: Record<string, string> = {
300
300
  >
301
301
  <div
302
302
  class="group flex cursor-pointer items-center gap-2"
303
+ data-testid="pipeline-step"
304
+ :data-step-kind="s.agentKind"
303
305
  :title="
304
306
  s.output
305
307
  ? t('pipeline.progress.viewDetailsOutput')
@@ -18,10 +18,18 @@
18
18
  * review windows) can flush it in one place instead of every caller having to remember to.
19
19
  * It runs synchronously; if it kicks off async work it must capture whatever it needs first,
20
20
  * because `blockId`/the derived state go null the moment the view closes.
21
+ *
22
+ * `manageEscape` (default `true`) owns the global Escape-to-close listener. A window that
23
+ * renders through `ResultWindowShell` (slice 5 of the modular-vue adoption) passes `false`:
24
+ * the shell's `useModalBehavior` owns Escape via the shared overlay stack (so the top
25
+ * overlay closes first, and focus/scroll are managed too), and a second listener here would
26
+ * double-fire `close`. Un-converted windows keep the default so they still close on Escape.
27
+ * Remove this option once every result window is on the shell (the listener moves out
28
+ * entirely).
21
29
  */
22
30
  export function useResultView(
23
31
  viewId: string,
24
- opts?: { onOpen?: (blockId: string) => void; onClose?: () => void },
32
+ opts?: { onOpen?: (blockId: string) => void; onClose?: () => void; manageEscape?: boolean },
25
33
  ) {
26
34
  const ui = useUiStore()
27
35
 
@@ -42,8 +50,10 @@ export function useResultView(
42
50
  function onKey(e: KeyboardEvent) {
43
51
  if (e.key === 'Escape' && open.value) close()
44
52
  }
45
- onMounted(() => window.addEventListener('keydown', onKey))
46
- onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
53
+ if (opts?.manageEscape !== false) {
54
+ onMounted(() => window.addEventListener('keydown', onKey))
55
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
56
+ }
47
57
 
48
58
  // The load-on-open contract: fire immediately on mount and on any later block switch.
49
59
  if (opts?.onOpen) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.136.1",
3
+ "version": "0.137.0",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,12 +18,12 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@modular-frontend/core": "^0.4.0",
22
- "@modular-vue/core": "^1.3.0",
21
+ "@modular-frontend/core": "^0.5.0",
22
+ "@modular-vue/core": "^1.4.0",
23
23
  "@modular-vue/journeys": "^1.3.0",
24
24
  "@modular-vue/nuxt": "^0.4.0",
25
25
  "@modular-vue/runtime": "^1.4.1",
26
- "@modular-vue/vue": "^1.3.0",
26
+ "@modular-vue/vue": "^1.4.0",
27
27
  "@nuxt/ui": "^4.10.0",
28
28
  "@nuxtjs/i18n": "^10.4.1",
29
29
  "@pinia/nuxt": "^1.0.1",