@cat-factory/app 0.315.4 → 0.315.7

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.
Files changed (40) hide show
  1. package/README.md +3 -1
  2. package/app/components/bootstrap/BootstrapModal.vue +1 -1
  3. package/app/components/common/ConfirmDialog.vue +5 -28
  4. package/app/components/common/TakeoverShell.vue +97 -0
  5. package/app/components/focus/BlockFocusView.vue +24 -3
  6. package/app/components/github/AddServiceFromRepoModal.vue +2 -2
  7. package/app/components/github/GitHubPanel.vue +1 -1
  8. package/app/components/kaizen/KaizenPanel.vue +6 -11
  9. package/app/components/media/ArtifactLightbox.vue +13 -21
  10. package/app/components/observability/StepModelActivity.vue +1 -0
  11. package/app/components/panels/AgentStepDetail.vue +8 -12
  12. package/app/components/panels/InspectorPanel.vue +6 -1
  13. package/app/components/panels/ObservabilityPanel.vue +7 -11
  14. package/app/components/panels/OperatorDashboardPanel.vue +6 -11
  15. package/app/components/panels/ReportsPanel.vue +6 -11
  16. package/app/components/panels/inspector/ServiceTestConfig.vue +2 -2
  17. package/app/components/pipeline/PipelineBuilder.vue +4 -0
  18. package/app/components/settings/ModelConfigurationPanel.vue +21 -13
  19. package/app/plugins/foreign-layers.client.ts +8 -0
  20. package/app/stores/github/connection.ts +2 -4
  21. package/app/stores/github/context.ts +5 -1
  22. package/app/stores/github/projectionLoad.ts +127 -0
  23. package/app/stores/github/repoActions.ts +7 -2
  24. package/app/stores/github.spec.ts +355 -1
  25. package/app/stores/github.ts +21 -22
  26. package/app/stores/ui/builderModelConfigHandoff.spec.ts +88 -0
  27. package/app/stores/ui/modals.ts +78 -12
  28. package/app/utils/foreignLayers.spec.ts +100 -0
  29. package/app/utils/foreignLayers.ts +71 -0
  30. package/i18n/locales/de.json +1 -0
  31. package/i18n/locales/en.json +1 -0
  32. package/i18n/locales/es.json +1 -0
  33. package/i18n/locales/fr.json +1 -0
  34. package/i18n/locales/he.json +1 -0
  35. package/i18n/locales/it.json +1 -0
  36. package/i18n/locales/ja.json +1 -0
  37. package/i18n/locales/pl.json +1 -0
  38. package/i18n/locales/tr.json +1 -0
  39. package/i18n/locales/uk.json +1 -0
  40. package/package.json +1 -1
package/README.md CHANGED
@@ -388,7 +388,9 @@ A phone's notch and home indicator cover whatever sits at the screen edge, and o
388
388
  - **`FULL_BLEED_TAKEOVER_CLASS`** for a takeover that fills the screen (`AgentStepDetail`, the model configuration takeover, the observability, reports, operator dashboard and Kaizen panels, the artifact lightbox). It pads by the inset alone, so a screen without one keeps the edge-to-edge layout.
389
389
  - **`WINDOWED_OVERLAY_CLASS`** for a window on a backdrop (`ResultWindowShell`). Its gutter is 1rem or the inset, whichever is larger.
390
390
 
391
- `scripts/check-frontend-safe-area.mjs` (CI) refuses any other `fixed` surface that spans a whole side of the viewport, in `.vue`, `.ts` and `.css`. A surface with no edge content (a scrim, a drawing layer) or one that pads its own edges (the sidebar drawer, the inspector's bottom sheet) says why with a `safe-area-ok:` comment, on the line or the one above it, or above the element's opening tag.
391
+ Each class list belongs to ONE shell, and a surface renders inside that shell instead of binding the class itself: a takeover inside [`common/TakeoverShell.vue`](./app/components/common/TakeoverShell.vue), a window inside [`panels/ResultWindowShell.vue`](./app/components/panels/ResultWindowShell.vue). The shell binds the padding AND the modal behaviour (`useModalBehavior`: a focus trap, focus return, a body-scroll lock, and a ticket on the shared overlay stack), so focus stays inside and Escape closes only the top overlay. A takeover gates the shell with its own `v-if` inside its own `Teleport` and `Transition`, passes a `label` (the dialog's accessible name), and handles the shell's `close` event; it never adds an Escape listener of its own. A Nuxt UI menu, select, popover or modal opened inside a shell holds its own ticket on the same stack while it is open ([`utils/foreignLayers.ts`](./app/utils/foreignLayers.ts), wired by a client plugin), so Escape and Tab are its keys until it closes. No site pushes a ticket by hand. Why this is not `UModal fullscreen`, and the rest of the overlay consolidation: [`docs/initiatives/overlay-shells.md`](https://github.com/kibertoad/cat-factory/blob/main/docs/initiatives/overlay-shells.md).
392
+
393
+ `scripts/check-frontend-safe-area.mjs` (CI) refuses any other `fixed` surface that spans a whole side of the viewport, in `.vue`, `.ts` and `.css`. A surface with no edge content (a scrim, a drawing layer) or one that pads its own edges (the sidebar drawer, the inspector's bottom sheet) says why with a `safe-area-ok:` comment, on the line or the one above it, or above the element's opening tag. The same guard refuses either class list named outside its owning shell, with no waiver.
392
394
 
393
395
  ### Modals open at a named width
394
396
 
@@ -54,7 +54,7 @@ watch(
54
54
  async function loadGitHubRepos() {
55
55
  try {
56
56
  await github.probe()
57
- if (github.connected) await github.load()
57
+ await github.refresh()
58
58
  } catch {
59
59
  // GitHub integration off / unreachable → the repo picker just isn't offered.
60
60
  }
@@ -2,39 +2,16 @@
2
2
  // The single, app-wide confirmation dialog. Mounted once in `pages/index.vue` and driven
3
3
  // entirely by the `useConfirm()` singleton, so any caller can `await confirm({...})` without
4
4
  // rendering its own modal. UModal already provides the focus trap, Escape-to-close and
5
- // backdrop, so this component adds none of that — it only resolves the pending promise and,
6
- // crucially, resolves `false` whenever the modal closes without an explicit choice.
7
- import { computed, onUnmounted, watch } from 'vue'
8
- import { sharedOverlayStack, type OverlayStackTicket } from '@modular-vue/core'
5
+ // backdrop, so this component adds none of that. It only resolves the pending promise, and
6
+ // resolves `false` whenever the modal closes without an explicit choice. Its place on
7
+ // the shared overlay stack, which makes a takeover or window under it leave Escape to it, comes
8
+ // from `utils/foreignLayers.ts`, as for every Nuxt UI layer.
9
+ import { computed } from 'vue'
9
10
  import AppModal from '~/components/common/AppModal.vue'
10
11
 
11
12
  const { t } = useI18n()
12
13
  const { open, current, accept, cancel, dismissed } = useConfirm()
13
14
 
14
- // Register on the app's ONE overlay stack for as long as the dialog is up.
15
- //
16
- // The stack is what `useModalBehavior` (`ResultWindowShell`, the lightbox, every modular overlay)
17
- // asks before it handles Escape, and this dialog is a Nuxt UI modal, so without a registration it
18
- // is invisible to that question. A confirm opened FROM a result window then lost the race for its
19
- // own Escape key: the shell's capture-phase listener still believed it was topmost, so it
20
- // preventDefault-ed and re-entered its close request, which supersedes the confirm the user was
21
- // trying to cancel. Pushing a ticket makes the shell stand down while this is on top, which is
22
- // exactly the ordering the stack exists to state.
23
- let ticket: OverlayStackTicket | null = null
24
- function release(): void {
25
- ticket?.release()
26
- ticket = null
27
- }
28
- watch(
29
- open,
30
- (isOpen) => {
31
- if (isOpen) ticket ??= sharedOverlayStack.push()
32
- else release()
33
- },
34
- { immediate: true },
35
- )
36
- onUnmounted(release)
37
-
38
15
  const model = computed({
39
16
  get: () => open.value,
40
17
  set: (v: boolean) => {
@@ -0,0 +1,97 @@
1
+ <script setup lang="ts">
2
+ // The one root of every full-bleed takeover: the step reader (`AgentStepDetail`), the model
3
+ // configuration takeover, the observability, reports, operator dashboard and Kaizen panels, and the
4
+ // artifact lightbox. It owns the root element and the modal BEHAVIOUR; each takeover keeps its own
5
+ // layout, header and backdrop inside it.
6
+ //
7
+ // The behaviour is the upstream `useModalBehavior` (`@modular-vue/core`), the same composable
8
+ // `ResultWindowShell` uses: a focus trap, focus return, a body-scroll lock, and a ticket on the
9
+ // shared overlay STACK, so Escape closes only the top overlay. Before this shell, six of the seven
10
+ // takeovers hand-rolled a window Escape listener instead. Tab then walked out to the board behind
11
+ // them, and one Escape closed every overlay that listened, so a result window or the observability
12
+ // panel opened over the step reader took the reader down with it.
13
+ //
14
+ // Why not `UModal fullscreen` (the shape `GuidedReviewModal` uses): a Reka modal makes the body
15
+ // inert while it is up (`pointer-events: none` on `<body>`, a `focusin` trap, and a dismiss on any
16
+ // pointer or focus outside its content). The windows opened ON TOP of a takeover
17
+ // (`ResultWindowShell`, the lightbox, a modular overlay) are not Reka layers, so they would open
18
+ // unclickable and lose focus to the takeover under them. The shared stack is what both sides
19
+ // agree on: every open Nuxt UI menu, select, popover and modal holds a ticket on it
20
+ // (`utils/foreignLayers.ts`), so one opened inside a takeover is the top overlay and keeps its own
21
+ // Escape.
22
+ //
23
+ // Every takeover paints at the SAME z-index (`z-50`, as `ResultWindowShell` does) and is teleported
24
+ // to `<body>` when it opens, so DOM order is open order: the overlay stack's top ticket is also the
25
+ // top layer. A per-site z-index let a later-opened `z-50` takeover take focus and Escape while
26
+ // painting under an earlier `z-[60]` one.
27
+ //
28
+ // The takeover is OPEN while this shell is mounted: each site gates it with its own `v-if` inside
29
+ // its own `Teleport` and `Transition`, so a fade owned by a parent mount (`pages/index.vue` for the
30
+ // step reader) still plays in both directions.
31
+ import { useMounted } from '@vueuse/core'
32
+ import { useModalBehavior } from '@modular-vue/core'
33
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
34
+
35
+ const props = defineProps<{
36
+ /** The accessible name of the dialog. */
37
+ label: string
38
+ /**
39
+ * What the takeover shows now, for a takeover that swaps its content without closing (the step
40
+ * reader moving to another step). A change moves focus back to the top of the dialog.
41
+ */
42
+ contentKey?: string | number | null
43
+ }>()
44
+
45
+ const emit = defineEmits<{ close: [] }>()
46
+
47
+ // The behaviour activates on MOUNT, not in setup. Most takeovers are async views, and on their first
48
+ // open Nuxt's Suspense defers the mount, so focus moved during setup would find no element yet and
49
+ // stay on the board.
50
+ const mounted = useMounted()
51
+
52
+ // Initial focus goes to the dialog root, not to its first control: a takeover is a whole screen of
53
+ // content, so the reader starts at its top and the first Tab reaches the first control.
54
+ const { dialogRef, isTop } = useModalBehavior({
55
+ active: mounted,
56
+ onClose: () => emit('close'),
57
+ initialFocus: () => dialogRef.value,
58
+ contentKey: () => props.contentKey,
59
+ })
60
+
61
+ // The trap wraps Tab only when focus is outside the dialog or on its first or last control, and the
62
+ // root is none of those, so the first Shift+Tab from the root would leave for the board behind
63
+ // (the browser moves to the last tabbable element before the root in DOM order). Wrap it here.
64
+ // Hidden controls (`display: none`, such as the reader's outline below `md`) cannot take focus, so
65
+ // they are skipped.
66
+ const FOCUSABLE =
67
+ 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
68
+
69
+ function wrapShiftTabFromRoot(event: KeyboardEvent): void {
70
+ const root = dialogRef.value
71
+ if (event.key !== 'Tab' || !event.shiftKey || !root || event.target !== root) return
72
+ const controls = [...root.querySelectorAll<HTMLElement>(FOCUSABLE)]
73
+ const last = controls.findLast((el) => el.checkVisibility())
74
+ if (!last) return
75
+ event.preventDefault()
76
+ last.focus()
77
+ }
78
+
79
+ // A takeover with its own keys (the lightbox's arrows and zoom) reads this, so its keys stay inert
80
+ // while another overlay is on top of it.
81
+ defineExpose({ isTop })
82
+ </script>
83
+
84
+ <template>
85
+ <div
86
+ ref="dialogRef"
87
+ tabindex="-1"
88
+ role="dialog"
89
+ aria-modal="true"
90
+ :aria-label="label"
91
+ class="z-50 focus:outline-none"
92
+ :class="FULL_BLEED_TAKEOVER_CLASS"
93
+ @keydown="wrapShiftTabFromRoot"
94
+ >
95
+ <slot />
96
+ </div>
97
+ </template>
@@ -1,5 +1,7 @@
1
1
  <script setup lang="ts">
2
- import { onKeyStroke } from '@vueuse/core'
2
+ import { onBeforeUnmount, onMounted } from 'vue'
3
+ import { useEventListener } from '@vueuse/core'
4
+ import { sharedOverlayStack, type OverlayStackTicket } from '@modular-vue/core'
3
5
  import type { Block } from '~/types/domain'
4
6
  import { blockTypeMeta, STATUS_META } from '~/utils/catalog'
5
7
  import { pipelineAllowedForManualStart } from '~/utils/pipeline'
@@ -79,9 +81,28 @@ function close() {
79
81
  ui.focus(null)
80
82
  }
81
83
 
82
- onKeyStroke('Escape', () => {
83
- if (ui.focusBlockId) close()
84
+ // The focus view is one more layer on the shared overlay stack: it holds a ticket while mounted and
85
+ // takes Escape only when that ticket is the top one. A takeover, window, Nuxt UI menu or modal
86
+ // opened from here is pushed after it, so it is the top overlay and keeps its own Escape. The
87
+ // listener is in the CAPTURE phase and registered at setup, so it runs before the listener of any
88
+ // overlay opened later and reads the stack as it was when the key was pressed, before that
89
+ // overlay's close can release its ticket.
90
+ let ticket: OverlayStackTicket | null = null
91
+ onMounted(() => {
92
+ ticket = sharedOverlayStack.push()
84
93
  })
94
+ onBeforeUnmount(() => {
95
+ ticket?.release()
96
+ ticket = null
97
+ })
98
+ useEventListener(
99
+ window,
100
+ 'keydown',
101
+ (e: KeyboardEvent) => {
102
+ if (e.key === 'Escape' && ticket?.isTop() && ui.focusBlockId) close()
103
+ },
104
+ { capture: true },
105
+ )
85
106
 
86
107
  function openDecisionFor(decisionId: string) {
87
108
  if (instance.value) ui.openDecision(instance.value.id, decisionId)
@@ -59,10 +59,10 @@ const adding = ref(false)
59
59
  async function loadRepos() {
60
60
  try {
61
61
  await github.probe()
62
- // Load only the linked-repo projection (to flag repos already on the board). The
62
+ // Load the linked-repo projection once per board (to flag repos already on the board). The
63
63
  // available-repos list is NOT prefetched — it's searched server-side on demand once
64
64
  // the user types (see the repoQueryRaw watcher), so a wide install isn't shipped whole.
65
- if (github.connected) await github.load()
65
+ await github.refresh()
66
66
  } catch {
67
67
  // Integration off / unreachable → the picker stays empty, GitHubConnect shows.
68
68
  }
@@ -100,7 +100,7 @@ watch(
100
100
  open,
101
101
  (isOpen) => {
102
102
  if (!isOpen) return
103
- if (github.connected) void github.load()
103
+ if (github.connected) void github.refresh()
104
104
  },
105
105
  { immediate: true },
106
106
  )
@@ -1,11 +1,10 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { TableColumn } from '@nuxt/ui'
5
4
  import type { KaizenGrading } from '~/types/domain'
6
5
  import { agentKindMeta } from '~/utils/catalog'
7
6
  import EmptyState from '~/components/common/EmptyState.vue'
8
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
7
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
9
8
 
10
9
  // The Kaizen screen: a full-panel overlay listing the workspace's grading history and
11
10
  // its verified-combo library. Opened via `ui.openKaizen()` from the sidebar. Read-only —
@@ -27,9 +26,6 @@ watch(
27
26
  function close() {
28
27
  ui.closeKaizen()
29
28
  }
30
- onKeyStroke('Escape', () => {
31
- if (open.value) close()
32
- })
33
29
 
34
30
  function meta(kind: string) {
35
31
  return agentKindMeta(kind)
@@ -66,12 +62,11 @@ const historyColumns = computed<TableColumn<KaizenGrading>[]>(() => [
66
62
  <template>
67
63
  <Teleport to="body">
68
64
  <Transition name="kz-fade">
69
- <div
65
+ <TakeoverShell
70
66
  v-if="open"
71
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
72
- :class="FULL_BLEED_TAKEOVER_CLASS"
73
- role="dialog"
74
- aria-modal="true"
67
+ :label="t('kaizen.title')"
68
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
69
+ @close="close"
75
70
  >
76
71
  <header class="flex items-center gap-3 border-b border-default px-6 py-4">
77
72
  <div
@@ -241,7 +236,7 @@ const historyColumns = computed<TableColumn<KaizenGrading>[]>(() => [
241
236
  </UTable>
242
237
  </section>
243
238
  </div>
244
- </div>
239
+ </TakeoverShell>
245
240
  </Transition>
246
241
  </Teleport>
247
242
  </template>
@@ -7,11 +7,10 @@
7
7
  // Zoom is pure CSS `transform` (GPU, no canvas), so even large PNGs stay smooth. Keyboard:
8
8
  // Esc close · ←/→ prev/next · +/- zoom · 0 reset · double-click toggle fit↔2×.
9
9
  import { computed, ref, watch } from 'vue'
10
- import { useModalBehavior } from '@modular-vue/core'
11
10
  import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
12
11
  import IconButton from '~/components/common/IconButton.vue'
13
12
  import Spinner from '~/components/common/Spinner.vue'
14
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
13
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
15
14
 
16
15
  interface LightboxItem {
17
16
  artifactId: string
@@ -39,19 +38,15 @@ const scale = ref(1)
39
38
  const tx = ref(0)
40
39
  const ty = ref(0)
41
40
 
42
- // Modal behaviour via the shared overlay stack (`@modular-vue/core`, the slice-5 release):
43
- // focus-trap + return, body-scroll lock, and — because it pushes onto the shared stack while
44
- // open — it becomes the TOP overlay over an owning review window (`ResultWindowShell`), so
45
- // Escape closes the lightbox first and the owner's trap goes inert until it closes. This is
46
- // the reconciliation that replaces the old `active: open && !lightboxOpen` guard the windows
47
- // used to fight over Tab.
41
+ // Modal behaviour comes from `TakeoverShell` (the shared overlay stack): focus-trap + return,
42
+ // body-scroll lock, and, because it pushes onto the shared stack while open, it becomes the TOP
43
+ // overlay over an owning review window (`ResultWindowShell`), so Escape closes the lightbox first
44
+ // and the owner's trap goes inert until it closes.
48
45
  function close() {
49
46
  emit('update:open', false)
50
47
  }
51
- const { dialogRef, isTop } = useModalBehavior({
52
- active: () => props.open,
53
- onClose: close,
54
- })
48
+ const shell = ref<InstanceType<typeof TakeoverShell> | null>(null)
49
+ const isTop = computed(() => shell.value?.isTop ?? false)
55
50
 
56
51
  const current = computed(() => props.items[props.index] ?? null)
57
52
  const total = computed(() => props.items.length)
@@ -159,20 +154,17 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
159
154
 
160
155
  <template>
161
156
  <Teleport to="body">
162
- <div
157
+ <TakeoverShell
163
158
  v-if="open"
164
- ref="dialogRef"
165
- tabindex="-1"
166
- class="z-[60] flex-col bg-app-950/95 backdrop-blur-sm focus:outline-none"
167
- :class="FULL_BLEED_TAKEOVER_CLASS"
168
- role="dialog"
169
- aria-modal="true"
159
+ ref="shell"
160
+ class="flex-col bg-app-950/95 backdrop-blur-sm"
170
161
  data-testid="artifact-lightbox"
171
- :aria-label="
162
+ :label="
172
163
  current
173
164
  ? t('media.lightbox.ariaLabel', { label: current.label })
174
165
  : t('media.lightbox.ariaLabelFallback')
175
166
  "
167
+ @close="close"
176
168
  >
177
169
  <!-- Toolbar -->
178
170
  <div class="flex items-center gap-3 border-b border-default/60 px-4 py-2.5">
@@ -307,6 +299,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
307
299
  @click="go(1)"
308
300
  />
309
301
  </div>
310
- </div>
302
+ </TakeoverShell>
311
303
  </Teleport>
312
304
  </template>
@@ -39,6 +39,7 @@ function openObservability() {
39
39
  color="neutral"
40
40
  variant="ghost"
41
41
  v-if="instanceId"
42
+ data-testid="step-view-all-calls"
42
43
  class="p-0 text-2xs text-app-info-400 hover:bg-transparent hover:text-app-info-300"
43
44
  @click="openObservability"
44
45
  >
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { ref, computed, watch, onMounted } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { IterationCapChoice } from '~/types/execution'
5
4
  import { agentKindMeta } from '~/utils/catalog'
6
5
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
@@ -32,7 +31,7 @@ import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
32
31
  import SectionLabel from '~/components/common/SectionLabel.vue'
33
32
  import IconButton from '~/components/common/IconButton.vue'
34
33
  import EmptyState from '~/components/common/EmptyState.vue'
35
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
34
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
36
35
 
37
36
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
38
37
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -372,10 +371,6 @@ watch(
372
371
  },
373
372
  )
374
373
 
375
- onKeyStroke('Escape', () => {
376
- if (open.value) close()
377
- })
378
-
379
374
  const { copy } = useCopyToClipboard()
380
375
  async function copyOutput() {
381
376
  if (step.value?.output) await copy(step.value.output)
@@ -391,14 +386,15 @@ async function copyOutput() {
391
386
  <!-- `data-agent-kind` names WHICH step this window is scoped to, as the kind rather than as
392
387
  the heading beside it: the heading is a translated display label, so it is the wrong
393
388
  thing to read for anything that needs to know which agent's step is open. -->
394
- <div
389
+ <!-- `content-key` moves focus back to the top when the reader swaps to another step. -->
390
+ <TakeoverShell
395
391
  v-if="open && step && agent"
392
+ :label="agent.label"
393
+ :content-key="ctx && `${ctx.instanceId}:${ctx.stepIndex}`"
396
394
  data-testid="step-detail"
397
395
  :data-agent-kind="step?.agentKind"
398
- class="z-50 bg-app-950/96 backdrop-blur-sm"
399
- :class="FULL_BLEED_TAKEOVER_CLASS"
400
- role="dialog"
401
- aria-modal="true"
396
+ class="bg-app-950/96 backdrop-blur-sm"
397
+ @close="close"
402
398
  >
403
399
  <!-- ToC sidebar (only meaningful when there are prose headings) -->
404
400
  <aside
@@ -1085,7 +1081,7 @@ async function copyOutput() {
1085
1081
  </p>
1086
1082
  </div>
1087
1083
  </aside>
1088
- </div>
1084
+ </TakeoverShell>
1089
1085
  </template>
1090
1086
 
1091
1087
  <style scoped>
@@ -24,10 +24,15 @@ const recurring = useRecurringPipelinesStore()
24
24
  const requirements = useRequirementsStore()
25
25
  const access = useWorkspaceAccess()
26
26
  const { t } = useI18n()
27
+ const { present } = usePipelineErrorToast()
27
28
 
28
29
  onMounted(() => {
29
30
  fragments.ensureLoaded()
30
- github.ensureLoaded()
31
+ // The repo list behind the inspector's repo link and the service config's detect-from-repo
32
+ // lookup. Both read the store, so this one call serves them.
33
+ github.ensureLoaded().catch((error: unknown) => {
34
+ present(error, 'github.connect.errors.loadProjection')
35
+ })
31
36
  })
32
37
 
33
38
  const block = computed<Block | undefined>(() =>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, reactive, ref, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { TableColumn } from '@nuxt/ui'
5
4
  import { isLlmWarningFinishReason } from '@cat-factory/contracts'
6
5
  import type {
@@ -33,7 +32,7 @@ import ToolCallList from '~/components/observability/ToolCallList.vue'
33
32
  import SectionLabel from '~/components/common/SectionLabel.vue'
34
33
  import IconButton from '~/components/common/IconButton.vue'
35
34
  import EmptyState from '~/components/common/EmptyState.vue'
36
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
35
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
37
36
 
38
37
  /** No run selected: the same empty, NOT-truncated trajectory the store answers with. */
39
38
  const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
@@ -526,9 +525,6 @@ function headroomOf(m: LlmCallMetric): number | null {
526
525
  function close() {
527
526
  ui.closeObservability()
528
527
  }
529
- onKeyStroke('Escape', () => {
530
- if (open.value) close()
531
- })
532
528
  function exportJson() {
533
529
  if (executionId.value) void observability.downloadExport(executionId.value)
534
530
  }
@@ -537,12 +533,12 @@ function exportJson() {
537
533
  <template>
538
534
  <Teleport to="body">
539
535
  <Transition name="obs-fade">
540
- <div
536
+ <TakeoverShell
541
537
  v-if="open"
542
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
543
- :class="FULL_BLEED_TAKEOVER_CLASS"
544
- role="dialog"
545
- aria-modal="true"
538
+ :label="t('observability.modelActivity')"
539
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
540
+ data-testid="observability-panel"
541
+ @close="close"
546
542
  >
547
543
  <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
548
544
  <div
@@ -1287,7 +1283,7 @@ function exportJson() {
1287
1283
  </div>
1288
1284
  </div>
1289
1285
  </div>
1290
- </div>
1286
+ </TakeoverShell>
1291
1287
  </Transition>
1292
1288
  </Teleport>
1293
1289
  </template>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import type { TableColumn } from '@nuxt/ui'
5
4
  import type { PlatformObservabilityWindow } from '~/types/execution'
6
5
  import { formatMs } from '~/utils/observability'
@@ -8,7 +7,7 @@ import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
8
7
  import SectionLabel from '~/components/common/SectionLabel.vue'
9
8
  import IconButton from '~/components/common/IconButton.vue'
10
9
  import EmptyState from '~/components/common/EmptyState.vue'
11
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
10
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
12
11
 
13
12
  // Deployment-level (platform-operator) observability dashboard: the aggregate health of the
14
13
  // active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
@@ -105,9 +104,6 @@ function refresh() {
105
104
  function close() {
106
105
  ui.closeOperatorDashboard()
107
106
  }
108
- onKeyStroke('Escape', () => {
109
- if (open.value) close()
110
- })
111
107
 
112
108
  // Load (and refresh) whenever the dashboard opens.
113
109
  watch(
@@ -122,13 +118,12 @@ watch(
122
118
  <template>
123
119
  <Teleport to="body">
124
120
  <Transition name="obs-fade">
125
- <div
121
+ <TakeoverShell
126
122
  v-if="open"
127
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
128
- :class="FULL_BLEED_TAKEOVER_CLASS"
129
- role="dialog"
130
- aria-modal="true"
123
+ :label="t('platformObservability.title')"
124
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
131
125
  data-testid="operator-dashboard"
126
+ @close="close"
132
127
  >
133
128
  <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
134
129
  <div
@@ -553,7 +548,7 @@ watch(
553
548
  </div>
554
549
  </div>
555
550
  </div>
556
- </div>
551
+ </TakeoverShell>
557
552
  </Transition>
558
553
  </Teleport>
559
554
  </template>
@@ -1,6 +1,5 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, ref, watch } from 'vue'
3
- import { onKeyStroke } from '@vueuse/core'
4
3
  import { lastCompleteRollupDay } from '@cat-factory/contracts'
5
4
  import type {
6
5
  ReportActivityDimension,
@@ -22,7 +21,7 @@ import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue
22
21
  import SectionLabel from '~/components/common/SectionLabel.vue'
23
22
  import IconButton from '~/components/common/IconButton.vue'
24
23
  import EmptyState from '~/components/common/EmptyState.vue'
25
- import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
24
+ import TakeoverShell from '~/components/common/TakeoverShell.vue'
26
25
 
27
26
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
28
27
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -179,9 +178,6 @@ function refresh() {
179
178
  function close() {
180
179
  ui.closeReports()
181
180
  }
182
- onKeyStroke('Escape', () => {
183
- if (open.value) close()
184
- })
185
181
 
186
182
  // Load (and refresh) whenever the panel opens.
187
183
  watch(
@@ -196,13 +192,12 @@ watch(
196
192
  <template>
197
193
  <Teleport to="body">
198
194
  <Transition name="reports-fade">
199
- <div
195
+ <TakeoverShell
200
196
  v-if="open"
201
- class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
202
- :class="FULL_BLEED_TAKEOVER_CLASS"
203
- role="dialog"
204
- aria-modal="true"
197
+ :label="t('reports.title')"
198
+ class="flex-col bg-app-950/96 backdrop-blur-sm"
205
199
  data-testid="reports-panel"
200
+ @close="close"
206
201
  >
207
202
  <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
208
203
  <div
@@ -575,7 +570,7 @@ watch(
575
570
  </section>
576
571
  </div>
577
572
  </div>
578
- </div>
573
+ </TakeoverShell>
579
574
  </Transition>
580
575
  </Teleport>
581
576
  </template>
@@ -50,10 +50,10 @@ const ui = useUiStore()
50
50
  const { t, te } = useI18n()
51
51
 
52
52
  // The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
53
- // The repo list backs the detect-from-repo affordance (owner/name lookup).
53
+ // The repo list behind the detect-from-repo affordance (owner/name lookup) is loaded by the
54
+ // surface that hosts this section: the inspector panel, or the add-service modal on open.
54
55
  onMounted(() => {
55
56
  void infra.ensureLoaded()
56
- void github.ensureLoaded()
57
57
  })
58
58
 
59
59
  // The service's declared provision type (absent ⇒ treated as `infraless`: no environment
@@ -252,6 +252,8 @@ const open = computed({
252
252
  // knows whether to offer the post-release-health gate (it's loaded on demand, not from
253
253
  // the snapshot). Best-effort: a failure just leaves the gate hidden.
254
254
  onModalOpen(open, () => {
255
+ // Coming back from the model configuration, where none of this changed.
256
+ if (ui.consumeBuilderReturn()) return
255
257
  releaseHealth.load().catch(() => {})
256
258
  // The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
257
259
  // Best-effort: the builder is fully usable without it, and a deployment that wires no
@@ -404,6 +406,7 @@ const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelin
404
406
  :title="t('pipeline.builder.title')"
405
407
  side="left"
406
408
  :ui="{ content: 'max-w-[90vw] sm:max-w-2xl lg:max-w-5xl xl:max-w-6xl' }"
409
+ @after:leave="ui.builderLeft()"
407
410
  >
408
411
  <template #body>
409
412
  <!-- On compact viewports the three sections stack and the slideover body
@@ -443,6 +446,7 @@ const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelin
443
446
  size="xs"
444
447
  icon="i-lucide-cpu"
445
448
  :title="t('pipeline.builder.configureModelsTooltip')"
449
+ data-testid="pipeline-builder-configure-models"
446
450
  @click="ui.openModelConfig()"
447
451
  >
448
452
  {{ t('pipeline.builder.configureModels') }}