@cat-factory/app 0.310.3 → 0.311.1

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 (47) hide show
  1. package/README.md +17 -0
  2. package/app/assets/css/main.css +10 -0
  3. package/app/components/board/DependencyConnectOverlay.vue +1 -0
  4. package/app/components/common/AsyncViewError.vue +1 -0
  5. package/app/components/github/GitHubOnboarding.vue +9 -1
  6. package/app/components/kaizen/KaizenPanel.vue +3 -1
  7. package/app/components/layout/AccountTeamSettings.vue +1 -1
  8. package/app/components/layout/BoardSwitcher.vue +33 -14
  9. package/app/components/layout/DefaultTestEnvBanner.vue +4 -1
  10. package/app/components/layout/InfraSetupBanner.vue +26 -2
  11. package/app/components/layout/InfraSetupNotice.vue +69 -0
  12. package/app/components/layout/ProviderConfigBanner.vue +8 -1
  13. package/app/components/layout/SideBar.vue +4 -1
  14. package/app/components/media/ArtifactLightbox.vue +3 -2
  15. package/app/components/panels/AgentStepDetail.vue +5 -3
  16. package/app/components/panels/InspectorPanel.vue +2 -1
  17. package/app/components/panels/ObservabilityPanel.vue +19 -12
  18. package/app/components/panels/OperatorDashboardPanel.vue +36 -28
  19. package/app/components/panels/ReportsPanel.vue +3 -1
  20. package/app/components/panels/ResultWindowShell.logic.ts +4 -4
  21. package/app/components/panels/ResultWindowShell.vue +8 -3
  22. package/app/components/providers/AiProviderOnboardingModal.vue +7 -1
  23. package/app/components/providers/ApiKeysSection.vue +21 -2
  24. package/app/components/settings/ModelConfigurationPanel.vue +3 -1
  25. package/app/components/vcs/VcsConnectSurfaces.vue +30 -14
  26. package/app/composables/useSetupActor.ts +25 -0
  27. package/app/composables/useWorkspaceAccess.ts +1 -1
  28. package/app/modular/nav-gates.ts +1 -2
  29. package/app/stores/accounts.ts +8 -0
  30. package/app/stores/workspace/infraSetup.ts +11 -3
  31. package/app/stores/workspace.ts +3 -1
  32. package/app/types/domain.ts +2 -0
  33. package/app/utils/safeArea.spec.ts +33 -0
  34. package/app/utils/safeArea.ts +41 -0
  35. package/app/utils/setupAudience.spec.ts +115 -0
  36. package/app/utils/setupAudience.ts +102 -0
  37. package/i18n/locales/de.json +15 -1
  38. package/i18n/locales/en.json +15 -1
  39. package/i18n/locales/es.json +15 -1
  40. package/i18n/locales/fr.json +15 -1
  41. package/i18n/locales/he.json +15 -1
  42. package/i18n/locales/it.json +15 -1
  43. package/i18n/locales/ja.json +15 -1
  44. package/i18n/locales/pl.json +15 -1
  45. package/i18n/locales/tr.json +15 -1
  46. package/i18n/locales/uk.json +15 -1
  47. package/package.json +4 -4
package/README.md CHANGED
@@ -321,6 +321,15 @@ That is the whole mechanism, and it is why there is no rule here about WHICH ste
321
321
 
322
322
  The default `radius` lives on the `Cat Factory` document in `app/utils/theme/builtins.ts`. It equals `THEME_DEFAULTS.radius` and so writes no CSS, which is the point: changing the number there is the whole edit.
323
323
 
324
+ ### Full-screen overlays bind their root class
325
+
326
+ A phone's notch and home indicator cover whatever sits at the screen edge, and on a screen without them nothing looks wrong. So a full-screen overlay never spells `fixed inset-0` itself. Its root binds one of the two class lists in [`utils/safeArea.ts`](./app/utils/safeArea.ts), which carry the positioning, the `100dvh` cap and the safe-area padding, and the site adds only its stacking, direction and backdrop:
327
+
328
+ - **`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.
329
+ - **`WINDOWED_OVERLAY_CLASS`** for a window on a backdrop (`ResultWindowShell`). Its gutter is 1rem or the inset, whichever is larger.
330
+
331
+ `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.
332
+
324
333
  ### A control is its Nuxt UI component
325
334
 
326
335
  Every CONTROL in the SPA is the Nuxt UI component for it, never the raw HTML element. A raw `<button>` beside a `UButton` is the same control rendered twice, and only one of the two follows the theme, the focus ring, the disabled state and the size scale; the SPA carried 152 of them, 24 raw `<input>`, five hand-built tables and six hand-rolled `<details>`, each with its own border, background and hover recipe.
@@ -611,6 +620,14 @@ The seams, and what a new feature should use rather than reading the store ad ho
611
620
  - **A surface that narrows inline** reads `useUiRoleStore().fullSurface` (the frame header's bug-hunt
612
621
  button, the palette's per-connection integration commands). Same rule as the tier: what remains
613
622
  must be exactly what the full surface would have shown, only less of it.
623
+ - **A setup prompt** (a "configure this" card over the board) goes through `canActOnSetup`
624
+ (`app/utils/setupAudience.ts`) with the caller from `useSetupActor()`: it combines the role's
625
+ surface with the board and account grants and the owner the server reports for the gap
626
+ (`infraSetupOwners`). A caller who cannot act gets no card. If the gap stops runs they could start,
627
+ it reaches them as one line saying how it gets fixed (`blockingSetupNotice`): the owner to ask, or
628
+ the role switch when they hold the grant and only the designer surface hides the card. A viewer
629
+ gets nothing, and the line honours the card's dismissals. Never a button that opens a screen the
630
+ caller's save is refused on.
614
631
  - **A tutorial tour whose step clicks a non-`intake` nav entry declares
615
632
  `TUTORIAL_REQUIREMENTS.fullSurface`.** Which tours those are is not a judgement call:
616
633
  `tutorial-tours.spec.ts` derives the pairing from `navItemVisible`, so a tour that gains such a
@@ -84,6 +84,16 @@ body,
84
84
  height: 100%;
85
85
  }
86
86
 
87
+ /* No pull-to-refresh and no scroll chaining out of the app. The SPA is a full-screen canvas whose
88
+ * body never scrolls, so a vertical overscroll that reaches the viewport (from a modal's header,
89
+ * the end of a list, a gutter) has nothing to scroll and on a phone would reload the app instead,
90
+ * dropping whatever was open. Setting it on the root once covers every overlay, and inner scrollers
91
+ * still chain to the scroller around them. */
92
+ html,
93
+ body {
94
+ overscroll-behavior-y: none;
95
+ }
96
+
87
97
  body {
88
98
  margin: 0;
89
99
  overflow: hidden;
@@ -7,6 +7,7 @@ const { connecting } = useDependencyConnect()
7
7
  </script>
8
8
 
9
9
  <template>
10
+ <!-- safe-area-ok: a pointer-events-none drawing layer with no controls or text -->
10
11
  <svg
11
12
  v-if="connecting"
12
13
  class="pointer-events-none fixed inset-0 z-50 h-full w-full overflow-visible"
@@ -9,6 +9,7 @@ function reload() {
9
9
  </script>
10
10
 
11
11
  <template>
12
+ <!-- safe-area-ok: a scrim with one card centred on it, so no content reaches an edge -->
12
13
  <div
13
14
  data-testid="async-view-error"
14
15
  class="fixed inset-0 z-50 grid place-items-center bg-app-950/80 p-6 backdrop-blur-sm"
@@ -3,8 +3,11 @@
3
3
  // has no connection yet. cat-factory's whole flow runs on a connected repository host (agents
4
4
  // open pull/merge requests on the user's repos), so the board is withheld until the workspace
5
5
  // connects one. <VcsConnectSurfaces> renders whichever connect methods the deployment serves; a
6
- // "Sign out" escape hatch avoids trapping a user who needs to switch accounts.
6
+ // "Sign out" escape hatch avoids trapping a user who needs to switch accounts. The board switcher is
7
+ // the other escape: the gate replaces the whole board, sidebar included, so without it a member
8
+ // who landed on a board they cannot connect had no way to reach a board they can.
7
9
  import VcsConnectSurfaces from '~/components/vcs/VcsConnectSurfaces.vue'
10
+ import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
8
11
  import { VCS_PROVIDER_ICONS, VCS_PROVIDER_LABELS } from '~/utils/vcs'
9
12
 
10
13
  const { t } = useI18n()
@@ -39,6 +42,11 @@ const title = computed(() =>
39
42
 
40
43
  <VcsConnectSurfaces :app-intro="t('github.onboarding.appIntro')" />
41
44
 
45
+ <div class="mt-6 border-t border-default pt-4" data-testid="vcs-onboarding-switch-board">
46
+ <p class="mb-2 text-xs text-muted">{{ t('vcs.onboarding.switchBoard') }}</p>
47
+ <BoardSwitcher standalone />
48
+ </div>
49
+
42
50
  <p
43
51
  v-if="auth.required && auth.user"
44
52
  class="mt-6 border-t border-default pt-4 text-center text-xs text-dimmed"
@@ -4,6 +4,7 @@ import { onKeyStroke } from '@vueuse/core'
4
4
  import type { TableColumn } from '@nuxt/ui'
5
5
  import type { KaizenGrading } from '~/types/domain'
6
6
  import { agentKindMeta } from '~/utils/catalog'
7
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
7
8
 
8
9
  // The Kaizen screen: a full-panel overlay listing the workspace's grading history and
9
10
  // its verified-combo library. Opened via `ui.openKaizen()` from the sidebar. Read-only —
@@ -66,7 +67,8 @@ const historyColumns = computed<TableColumn<KaizenGrading>[]>(() => [
66
67
  <Transition name="kz-fade">
67
68
  <div
68
69
  v-if="open"
69
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
70
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
71
+ :class="FULL_BLEED_TAKEOVER_CLASS"
70
72
  role="dialog"
71
73
  aria-modal="true"
72
74
  >
@@ -53,7 +53,7 @@ function invitationStatusLabel(status: InvitationStatus): string {
53
53
  }
54
54
 
55
55
  /** Whether the signed-in caller is an admin of this account (drives edit affordances). */
56
- const isAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
56
+ const isAdmin = computed(() => accounts.isActiveAccountAdmin)
57
57
  /**
58
58
  * Members / roles / invitations are org-scoped — the backend rejects membership on a
59
59
  * personal account. For a personal account we show a "create an organization" CTA in
@@ -12,7 +12,16 @@ import SectionLabel from '~/components/common/SectionLabel.vue'
12
12
  // account row folds away — it is a label with a menu duplicated inside the board menu's
13
13
  // reach — and the board button keeps only its glyph. Both dropdowns are unchanged, so
14
14
  // switching boards never requires expanding the sidebar first.
15
- withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
15
+ //
16
+ // `standalone` is for a host OUTSIDE the board page (the repository-host gate), where the
17
+ // page-level dialogs are not mounted. The items that open one are left out there: "Account
18
+ // settings" opens `AccountSettingsPanel` and "Delete board" awaits `ConfirmDialog`, so on the gate
19
+ // both would do nothing and then fire once a board page mounted, the delete against the board that
20
+ // was active when it was asked.
21
+ const props = withDefaults(defineProps<{ collapsed?: boolean; standalone?: boolean }>(), {
22
+ collapsed: false,
23
+ standalone: false,
24
+ })
16
25
 
17
26
  const { t } = useI18n()
18
27
 
@@ -65,14 +74,19 @@ const accountItems = computed<DropdownMenuItem[][]>(() => [
65
74
  },
66
75
  // Account settings: the unified per-account panel (team + roles + invitations + email
67
76
  // sender + account-tier fragment library). The panel itself handles personal accounts
68
- // (prompting to create an org for the team tab), so this is not gated.
69
- {
70
- label: t('layout.boardSwitcher.account.settings'),
71
- icon: 'i-lucide-settings',
72
- onSelect: () => ui.openAccountSettings(),
73
- },
77
+ // (prompting to create an org for the team tab), so this is not gated by role; it is left out
78
+ // only where the panel is not mounted (`standalone`).
79
+ ...(props.standalone
80
+ ? []
81
+ : [
82
+ {
83
+ label: t('layout.boardSwitcher.account.settings'),
84
+ icon: 'i-lucide-settings',
85
+ onSelect: () => ui.openAccountSettings(),
86
+ },
87
+ ]),
74
88
  // Admins can set the account-wide default provider new services inherit.
75
- ...(accounts.activeAccount?.roles?.includes('admin')
89
+ ...(accounts.isActiveAccountAdmin
76
90
  ? [
77
91
  {
78
92
  label: t('layout.boardSwitcher.account.defaultProvider'),
@@ -122,12 +136,17 @@ const boardItems = computed<DropdownMenuItem[][]>(() => [
122
136
  icon: 'i-lucide-pencil',
123
137
  onSelect: () => openPrompt('rename'),
124
138
  },
125
- {
126
- label: t('layout.boardSwitcher.board.delete'),
127
- icon: 'i-lucide-trash-2',
128
- color: 'error' as const,
129
- onSelect: () => void removeBoard(),
130
- },
139
+ // Delete confirms through the page's `ConfirmDialog`, absent in `standalone`.
140
+ ...(props.standalone
141
+ ? []
142
+ : [
143
+ {
144
+ label: t('layout.boardSwitcher.board.delete'),
145
+ icon: 'i-lucide-trash-2',
146
+ color: 'error' as const,
147
+ onSelect: () => void removeBoard(),
148
+ },
149
+ ]),
131
150
  ]
132
151
  : []),
133
152
  ],
@@ -22,14 +22,17 @@ const ui = useUiStore()
22
22
  const workspace = useWorkspaceStore()
23
23
  const settingsStore = useWorkspaceSettingsStore()
24
24
  const { canManageSettings } = useWorkspaceAccess()
25
+ const uiRole = useUiRoleStore()
25
26
 
26
27
  // Only prompt someone who can actually answer. A member/viewer cannot write workspace settings
27
28
  // (the endpoint is `settings.manage`-gated), so for them this would be an un-actionable nag
28
- // pointing at a screen that would refuse their save.
29
+ // pointing at a screen that would refuse their save. The designer surface never configures the
30
+ // platform, so it is kept off there too, whatever the grant.
29
31
  const show = computed(
30
32
  () =>
31
33
  workspace.ready &&
32
34
  canManageSettings.value &&
35
+ uiRole.fullSurface &&
33
36
  needsDefaultProvisioningChoice(settingsStore.settings) &&
34
37
  !ui.defaultProvisionDismissed,
35
38
  )
@@ -6,6 +6,11 @@
6
6
  // runtime whose account picked no backend — incl. Cloudflare without an ARTIFACT_BUCKET binding;
7
7
  // ephemeral test environments on any runtime that wires the integration).
8
8
  //
9
+ // Audience: a card renders only for a caller who can act on its area, per the server-reported
10
+ // owner (`infraSetupOwners`) and `canActOnSetup`. A caller who cannot (a member, a viewer, the
11
+ // designer surface, or anyone when the operator owns the gap) sees one compact line instead, and
12
+ // only for a gap that stops every run (`InfraSetupNotice`).
13
+ //
9
14
  // Positioning/stacking against the sibling advisory banners (AI-readiness, provider-config) is
10
15
  // owned by the shared, click-through banner column in `pages/index.vue` — so concurrent prompts
11
16
  // stack vertically instead of drawing on top of each other. This component only stacks its OWN
@@ -52,11 +57,14 @@ import {
52
57
  import type { DropdownMenuItem } from '@nuxt/ui'
53
58
  import type { InfraSetupArea, InfraSetupStatus } from '~/types/domain'
54
59
  import { infraSetupDismissalKey, type InfraSetupCardKind } from '~/utils/infraSetup'
60
+ import { blockingSetupNotice, canActOnSetup } from '~/utils/setupAudience'
61
+ import InfraSetupNotice from '~/components/layout/InfraSetupNotice.vue'
55
62
 
56
63
  const { t } = useI18n()
57
64
  const ui = useUiStore()
58
65
  const auth = useAuthStore()
59
66
  const workspace = useWorkspaceStore()
67
+ const { actor } = useSetupActor()
60
68
 
61
69
  // Severity order: no executor blocks EVERY agent, so it leads; a missing test environment blocks
62
70
  // only testing agents; missing storage only degrades the UI-tester's screenshots.
@@ -140,6 +148,9 @@ const visible = computed<AreaCard[]>(() => {
140
148
  return AREAS.filter((area) => {
141
149
  const kind = cardKind(status[area])
142
150
  if (!kind) return false
151
+ // A card is for the people who can act on it. Everyone else hears about a gap only when it
152
+ // stops their own work, through the one line below (`blockingSetupNotice`).
153
+ if (!canActOnSetup(workspace.infraSetupOwners?.[area], actor.value)) return false
143
154
  // Both dismissals are keyed by the CLAIM, not by the area: silencing "you haven't configured
144
155
  // this" must not also silence "you configured it and it is now down". The permanent dismissal
145
156
  // only ever covers a setup gap (`dismissPermanently` is offered nowhere else).
@@ -152,6 +163,18 @@ const visible = computed<AreaCard[]>(() => {
152
163
  }))
153
164
  })
154
165
 
166
+ // The one line for a caller who cannot act on a gap that stops every run. It honours BOTH
167
+ // dismissals of the card it stands in for, keyed by the same claim: someone who said "don't
168
+ // notify me again" on the card (before their role changed, say) is not nagged again by the line.
169
+ const notice = computed(() => {
170
+ const found = blockingSetupNotice(workspace.infraSetup, workspace.infraSetupOwners, actor.value)
171
+ if (!found) return null
172
+ if (ui.infraSetupSessionDismissed.includes(infraSetupDismissalKey(found.area, found.kind))) {
173
+ return null
174
+ }
175
+ return found.kind === 'setup' && dismissedForUser.value.includes(found.area) ? null : found
176
+ })
177
+
155
178
  /** Which card an area's status raises, or null when it raises none (`configured`/`not_applicable`). */
156
179
  function cardKind(status: InfraSetupStatus): InfraSetupCardKind | null {
157
180
  if (isInfraSetupHealthStatus(status)) return 'outage'
@@ -169,7 +192,7 @@ function titleKey(card: AreaCard): string {
169
192
  * The dismiss dropdown: the product wants the user asked WHICH kind of dismissal on close. An
170
193
  * outage offers the session option ONLY — see the fork note at the top of this file.
171
194
  */
172
- function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
195
+ function dismissMenu(card: Pick<AreaCard, 'area' | 'kind'>): DropdownMenuItem[][] {
173
196
  const session = {
174
197
  label: t('layout.infraSetupBanner.dismiss.session'),
175
198
  icon: 'i-lucide-clock',
@@ -195,11 +218,12 @@ function dismissMenu(card: AreaCard): DropdownMenuItem[][] {
195
218
  than an assertive `role="alert"` per card — an advisory setup nag shouldn't interrupt a
196
219
  screen reader, and up to three stacked alerts would spam it. -->
197
220
  <div
198
- v-if="visible.length > 0"
221
+ v-if="visible.length > 0 || notice"
199
222
  class="flex w-full flex-col items-center gap-2"
200
223
  role="status"
201
224
  aria-live="polite"
202
225
  >
226
+ <InfraSetupNotice v-if="notice" :notice="notice" :dismiss-menu="dismissMenu(notice)" />
203
227
  <!-- An OUTAGE reads red, a setup gap amber: one is something breaking now, the other is
204
228
  something never switched on, and a reader has to be able to tell at a glance. -->
205
229
  <div
@@ -0,0 +1,69 @@
1
+ <script setup lang="ts">
2
+ // The one line a caller sees for a setup gap they cannot fix from where they are, when it stops
3
+ // their runs. It replaces the full card (and its "Configure" button that would open a screen their
4
+ // save is refused on) with two facts: what does not work, and how it gets fixed. Compact on
5
+ // purpose: the caller has nothing to do here, so it must not cover the board the way an actionable
6
+ // card may.
7
+ import type { DropdownMenuItem } from '@nuxt/ui'
8
+ import type { BlockingInfraArea, SetupNotice, SetupRemedy } from '~/utils/setupAudience'
9
+
10
+ defineProps<{
11
+ notice: SetupNotice
12
+ /** The card's own session / permanent choice, built by the banner so both close the same claim. */
13
+ dismissMenu: DropdownMenuItem[][]
14
+ }>()
15
+
16
+ const { t } = useI18n()
17
+
18
+ // Exhaustive over the blocking areas and the remedies, so a new member of either fails typecheck
19
+ // here rather than rendering a raw key.
20
+ const STATE_KEYS: Record<BlockingInfraArea, Record<SetupNotice['kind'], string>> = {
21
+ agentExecutor: {
22
+ setup: 'layout.infraSetupNotice.agentExecutor.setup',
23
+ outage: 'layout.infraSetupNotice.agentExecutor.outage',
24
+ },
25
+ }
26
+ const REMEDY_KEYS: Record<SetupRemedy, string> = {
27
+ workspace_admin: 'layout.infraSetupNotice.owner.workspace_admin',
28
+ account_admin: 'layout.infraSetupNotice.owner.account_admin',
29
+ operator: 'layout.infraSetupNotice.owner.operator',
30
+ full_surface: 'layout.infraSetupNotice.fullSurface',
31
+ }
32
+ </script>
33
+
34
+ <template>
35
+ <div
36
+ class="pointer-events-auto flex w-full max-w-3xl items-center gap-3 rounded-xl border px-4 py-2 shadow-lg backdrop-blur"
37
+ :class="
38
+ notice.kind === 'outage'
39
+ ? 'border-app-error-500/50 bg-app-error-950/90'
40
+ : 'border-app-warning-500/50 bg-app-warning-950/90'
41
+ "
42
+ :data-testid="`infra-setup-notice-${notice.area}`"
43
+ :data-infra-remedy="notice.remedy"
44
+ >
45
+ <UIcon
46
+ :name="notice.kind === 'outage' ? 'i-lucide-plug-zap' : 'i-lucide-server-off'"
47
+ class="h-5 w-5 shrink-0"
48
+ :class="notice.kind === 'outage' ? 'text-app-error-400' : 'text-app-warning-400'"
49
+ />
50
+ <p
51
+ class="min-w-0 flex-1 text-sm"
52
+ :class="notice.kind === 'outage' ? 'text-app-error-100' : 'text-app-warning-100'"
53
+ >
54
+ {{ t(STATE_KEYS[notice.area][notice.kind]) }}
55
+ <span class="opacity-80">{{ t(REMEDY_KEYS[notice.remedy]) }}</span>
56
+ </p>
57
+ <UDropdownMenu :items="dismissMenu" :content="{ align: 'end' }">
58
+ <UButton
59
+ color="neutral"
60
+ variant="ghost"
61
+ size="xs"
62
+ icon="i-lucide-x"
63
+ class="shrink-0"
64
+ :aria-label="t('common.close')"
65
+ :data-testid="`infra-setup-notice-dismiss-${notice.area}`"
66
+ />
67
+ </UDropdownMenu>
68
+ </div>
69
+ </template>
@@ -6,11 +6,13 @@
6
6
  // link into its connect panel. Dismissible per session.
7
7
  import { computed, ref, watch } from 'vue'
8
8
  import type { ProviderConnectionKind } from '~/types/providerConnections'
9
+ import { canActOnSetup } from '~/utils/setupAudience'
9
10
 
10
11
  const { t } = useI18n()
11
12
  const ui = useUiStore()
12
13
  const workspace = useWorkspaceStore()
13
14
  const store = useProviderConnectionsStore()
15
+ const { actor } = useSetupActor()
14
16
 
15
17
  // Exhaustive per-kind title/action keys (leaf names mirror the kind verbatim).
16
18
  const TITLE_KEYS: Record<ProviderConnectionKind, string> = {
@@ -33,7 +35,12 @@ watch(
33
35
 
34
36
  const dismissed = ref(false)
35
37
  const pending = computed(() => store.needingConfig as ProviderConnectionKind[])
36
- const show = computed(() => pending.value.length > 0 && !dismissed.value)
38
+ // Only for the people whose save would land: the connection fields are a workspace-admin write,
39
+ // and a member or a designer could only open the panel and be refused.
40
+ const show = computed(
41
+ () =>
42
+ pending.value.length > 0 && !dismissed.value && canActOnSetup('workspace_admin', actor.value),
43
+ )
37
44
  </script>
38
45
 
39
46
  <template>
@@ -154,6 +154,7 @@ watch(
154
154
  enter-from-class="opacity-0"
155
155
  leave-to-class="opacity-0"
156
156
  >
157
+ <!-- safe-area-ok: a scrim with no content; the drawer below pads its own edges -->
157
158
  <div
158
159
  v-if="ui.mobileNavOpen"
159
160
  class="fixed inset-0 z-30 bg-app-950/60 backdrop-blur-sm lg:hidden"
@@ -165,6 +166,8 @@ watch(
165
166
  />
166
167
  </Transition>
167
168
 
169
+ <!-- safe-area-ok: below lg, an edge drawer that pads the top, bottom and inline-start insets
170
+ itself. The start side is physical left in LTR and right in RTL, hence the `rtl:` override. -->
168
171
  <aside
169
172
  ref="aside"
170
173
  data-testid="sidebar"
@@ -173,7 +176,7 @@ watch(
173
176
  :aria-modal="drawerOpen ? 'true' : undefined"
174
177
  :aria-label="isCompact ? t('nav.menu') : undefined"
175
178
  :inert="isCompact && !ui.mobileNavOpen"
176
- class="fixed inset-y-0 start-0 z-40 flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-e border-default bg-default/95 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur transition-transform duration-200 focus:outline-none lg:static lg:z-auto lg:translate-x-0 lg:bg-default/80"
179
+ class="fixed inset-y-0 start-0 z-40 flex h-full w-64 shrink-0 flex-col gap-4 overflow-y-auto border-e border-default bg-default/95 px-3 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] max-lg:ps-[calc(0.75rem+env(safe-area-inset-left))] max-lg:pt-[calc(0.75rem+env(safe-area-inset-top))] max-lg:rtl:ps-[calc(0.75rem+env(safe-area-inset-right))] backdrop-blur transition-transform duration-200 focus:outline-none lg:static lg:z-auto lg:translate-x-0 lg:bg-default/80"
177
180
  :class="[
178
181
  ui.mobileNavOpen
179
182
  ? 'translate-x-0'
@@ -10,6 +10,7 @@ import { computed, ref, watch } from 'vue'
10
10
  import { useModalBehavior } from '@modular-vue/core'
11
11
  import type { ArtifactBlobs } from '~/composables/useArtifactBlobs'
12
12
  import IconButton from '~/components/common/IconButton.vue'
13
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
13
14
 
14
15
  interface LightboxItem {
15
16
  artifactId: string
@@ -161,7 +162,8 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
161
162
  v-if="open"
162
163
  ref="dialogRef"
163
164
  tabindex="-1"
164
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/95 backdrop-blur-sm focus:outline-none"
165
+ class="z-[60] flex-col bg-app-950/95 backdrop-blur-sm focus:outline-none"
166
+ :class="FULL_BLEED_TAKEOVER_CLASS"
165
167
  role="dialog"
166
168
  aria-modal="true"
167
169
  data-testid="artifact-lightbox"
@@ -170,7 +172,6 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
170
172
  ? t('media.lightbox.ariaLabel', { label: current.label })
171
173
  : t('media.lightbox.ariaLabelFallback')
172
174
  "
173
- @click.self="close"
174
175
  >
175
176
  <!-- Toolbar -->
176
177
  <div class="flex items-center gap-3 border-b border-default/60 px-4 py-2.5">
@@ -31,6 +31,7 @@ import InputGateNotice from '~/components/inputGate/InputGateNotice.vue'
31
31
  import RunDetailLoadState from '~/components/panels/RunDetailLoadState.vue'
32
32
  import SectionLabel from '~/components/common/SectionLabel.vue'
33
33
  import IconButton from '~/components/common/IconButton.vue'
34
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
34
35
 
35
36
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
36
37
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -393,7 +394,8 @@ async function copyOutput() {
393
394
  v-if="open && step && agent"
394
395
  data-testid="step-detail"
395
396
  :data-agent-kind="step?.agentKind"
396
- class="fixed inset-0 z-50 flex max-h-[100dvh] bg-app-950/96 backdrop-blur-sm"
397
+ class="z-50 bg-app-950/96 backdrop-blur-sm"
398
+ :class="FULL_BLEED_TAKEOVER_CLASS"
397
399
  role="dialog"
398
400
  aria-modal="true"
399
401
  >
@@ -959,7 +961,7 @@ async function copyOutput() {
959
961
  <!-- edit-then-approve actions -->
960
962
  <div
961
963
  v-if="editing"
962
- class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]"
964
+ class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] lg:pb-3"
963
965
  >
964
966
  <UButton
965
967
  size="sm"
@@ -984,7 +986,7 @@ async function copyOutput() {
984
986
 
985
987
  <div
986
988
  v-else
987
- class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))]"
989
+ class="space-y-2 border-t border-default px-4 pt-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] lg:pb-3"
988
990
  >
989
991
  <UButton
990
992
  data-testid="step-approve"
@@ -339,6 +339,7 @@ const showOriginalDescription = ref(false)
339
339
  NOT `data-block-id`: that attribute is the board's card selector, which the canvas drivers
340
340
  measure geometry through (`utils/blockRects.ts`), and a panel answering to it would offer
341
341
  the arrows a rect that is not on the canvas at all. -->
342
+ <!-- safe-area-ok: a bottom sheet below lg; its scroller pads the bottom and side insets -->
342
343
  <div
343
344
  v-if="block && statusMeta && typeMeta"
344
345
  data-testid="inspector-panel"
@@ -352,7 +353,7 @@ const showOriginalDescription = ref(false)
352
353
  On compact viewports the panel is a bottom sheet capped to the visible
353
354
  height (dvh excludes mobile browser chrome). -->
354
355
  <div
355
- class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain px-4 pt-4 pb-[calc(1rem+env(safe-area-inset-bottom))] lg:max-h-[calc(100vh-7rem)]"
356
+ class="max-h-[80dvh] space-y-4 overflow-y-auto overscroll-contain pt-4 pr-[max(1rem,env(safe-area-inset-right))] pb-[calc(1rem+env(safe-area-inset-bottom))] pl-[max(1rem,env(safe-area-inset-left))] lg:max-h-[calc(100vh-7rem)]"
356
357
  >
357
358
  <!-- header -->
358
359
  <div class="flex items-start justify-between gap-2">
@@ -32,6 +32,7 @@ import RunFailureSummary from '~/components/observability/RunFailureSummary.vue'
32
32
  import ToolCallList from '~/components/observability/ToolCallList.vue'
33
33
  import SectionLabel from '~/components/common/SectionLabel.vue'
34
34
  import IconButton from '~/components/common/IconButton.vue'
35
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
35
36
 
36
37
  /** No run selected: the same empty, NOT-truncated trajectory the store answers with. */
37
38
  const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
@@ -537,17 +538,18 @@ function exportJson() {
537
538
  <Transition name="obs-fade">
538
539
  <div
539
540
  v-if="open"
540
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
541
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
542
+ :class="FULL_BLEED_TAKEOVER_CLASS"
541
543
  role="dialog"
542
544
  aria-modal="true"
543
545
  >
544
- <header class="flex items-center gap-3 border-b border-default px-6 py-4">
546
+ <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
545
547
  <div
546
548
  class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-app-info-500/15"
547
549
  >
548
550
  <UIcon name="i-lucide-activity" class="h-5 w-5 text-app-info-400" />
549
551
  </div>
550
- <div class="min-w-0">
552
+ <div class="min-w-0 flex-1">
551
553
  <h1 class="truncate text-base font-semibold text-highlighted">
552
554
  {{ t('observability.modelActivity') }}
553
555
  </h1>
@@ -555,7 +557,11 @@ function exportJson() {
555
557
  {{ block.title }} · {{ runSubtitle }}
556
558
  </p>
557
559
  </div>
558
- <div class="ms-auto flex items-center gap-1.5">
560
+ <!-- Below lg the controls take their own row, so the close button stays on screen.
561
+ The row scrolls sideways when the tabs are wider than the phone. -->
562
+ <div
563
+ class="order-last flex w-full min-w-0 items-center gap-1.5 overflow-x-auto lg:order-none lg:w-auto"
564
+ >
559
565
  <!-- Four VIEWS of the same run, which is what tabs mean. The trajectory is loaded
560
566
  lazily the first time the tools view is opened, so the change is watched rather
561
567
  than bound straight to `view`. -->
@@ -580,15 +586,16 @@ function exportJson() {
580
586
  >
581
587
  {{ t('observability.exportJson') }}
582
588
  </UButton>
583
- <IconButton
584
- icon="i-lucide-x"
585
- color="neutral"
586
- variant="ghost"
587
- size="sm"
588
- :label="t('observability.closeEsc')"
589
- @click="close"
590
- />
591
589
  </div>
590
+ <IconButton
591
+ class="lg:-ms-1.5"
592
+ icon="i-lucide-x"
593
+ color="neutral"
594
+ variant="ghost"
595
+ size="sm"
596
+ :label="t('observability.closeEsc')"
597
+ @click="close"
598
+ />
592
599
  </header>
593
600
 
594
601
  <div class="flex-1 overflow-auto px-6 py-6">