@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
@@ -7,6 +7,7 @@ import { formatMs } from '~/utils/observability'
7
7
  import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
8
8
  import SectionLabel from '~/components/common/SectionLabel.vue'
9
9
  import IconButton from '~/components/common/IconButton.vue'
10
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
10
11
 
11
12
  // Deployment-level (platform-operator) observability dashboard: the aggregate health of the
12
13
  // active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
@@ -122,24 +123,29 @@ watch(
122
123
  <Transition name="obs-fade">
123
124
  <div
124
125
  v-if="open"
125
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
126
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
127
+ :class="FULL_BLEED_TAKEOVER_CLASS"
126
128
  role="dialog"
127
129
  aria-modal="true"
128
130
  data-testid="operator-dashboard"
129
131
  >
130
- <header class="flex items-center gap-3 border-b border-default px-6 py-4">
132
+ <header class="flex flex-wrap items-center gap-3 border-b border-default px-6 py-4">
131
133
  <div
132
134
  class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-app-info-500/15"
133
135
  >
134
136
  <UIcon name="i-lucide-gauge" class="h-5 w-5 text-app-info-400" />
135
137
  </div>
136
- <div class="min-w-0">
138
+ <div class="min-w-0 flex-1">
137
139
  <h1 class="truncate text-base font-semibold text-highlighted">
138
140
  {{ t('platformObservability.title') }}
139
141
  </h1>
140
142
  <p v-if="accountName" class="truncate text-xs text-dimmed">{{ accountName }}</p>
141
143
  </div>
142
- <div class="ms-auto flex items-center gap-1.5">
144
+ <!-- Below lg the window tabs take their own row, so refresh and close stay on screen.
145
+ The row scrolls sideways when the tabs are wider than the phone. -->
146
+ <div
147
+ class="order-last flex w-full min-w-0 items-center gap-1.5 overflow-x-auto lg:order-none lg:w-auto"
148
+ >
143
149
  <UTabs
144
150
  :model-value="platform.window"
145
151
  :items="WINDOWS"
@@ -154,31 +160,33 @@ watch(
154
160
  <span :data-testid="`operator-window-${item.value}`">{{ item.label }}</span>
155
161
  </template>
156
162
  </UTabs>
157
- <IconButton
158
- color="neutral"
159
- variant="ghost"
160
- icon="i-lucide-refresh-cw"
161
- :label="t('platformObservability.refresh')"
162
- :ui="{
163
- base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
164
- leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
165
- }"
166
- data-testid="operator-refresh"
167
- @click="refresh"
168
- />
169
- <IconButton
170
- color="neutral"
171
- variant="ghost"
172
- icon="i-lucide-x"
173
- :label="t('platformObservability.close')"
174
- :ui="{
175
- base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
176
- leadingIcon: 'h-4 w-4',
177
- }"
178
- data-testid="operator-close"
179
- @click="close"
180
- />
181
163
  </div>
164
+ <IconButton
165
+ class="lg:-ms-1.5"
166
+ color="neutral"
167
+ variant="ghost"
168
+ icon="i-lucide-refresh-cw"
169
+ :label="t('platformObservability.refresh')"
170
+ :ui="{
171
+ base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
172
+ leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
173
+ }"
174
+ data-testid="operator-refresh"
175
+ @click="refresh"
176
+ />
177
+ <IconButton
178
+ class="-ms-1.5"
179
+ color="neutral"
180
+ variant="ghost"
181
+ icon="i-lucide-x"
182
+ :label="t('platformObservability.close')"
183
+ :ui="{
184
+ base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
185
+ leadingIcon: 'h-4 w-4',
186
+ }"
187
+ data-testid="operator-close"
188
+ @click="close"
189
+ />
182
190
  </header>
183
191
 
184
192
  <div class="flex-1 overflow-y-auto px-6 py-5">
@@ -21,6 +21,7 @@ import {
21
21
  import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
22
22
  import SectionLabel from '~/components/common/SectionLabel.vue'
23
23
  import IconButton from '~/components/common/IconButton.vue'
24
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
24
25
 
25
26
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
26
27
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -196,7 +197,8 @@ watch(
196
197
  <Transition name="reports-fade">
197
198
  <div
198
199
  v-if="open"
199
- class="fixed inset-0 z-[60] flex flex-col bg-app-950/96 backdrop-blur-sm"
200
+ class="z-[60] flex-col bg-app-950/96 backdrop-blur-sm"
201
+ :class="FULL_BLEED_TAKEOVER_CLASS"
200
202
  role="dialog"
201
203
  aria-modal="true"
202
204
  data-testid="reports-panel"
@@ -11,10 +11,10 @@ export type ResultWindowWidth = '3xl' | '4xl' | '5xl' | 'full'
11
11
 
12
12
  /**
13
13
  * The bucket → cap mapping. `full` is deliberately `max-w-none` rather than a bigger number:
14
- * the panel's `w-full` then spans the backdrop, which the variant insets by one gutter (`m-4`
15
- * stretched, `p-4` centered), so the window fills the screen and still reads as a window rather
16
- * than a repaint of the app. A `Record` over the union, so a new bucket fails to compile until
17
- * it is mapped.
14
+ * the panel's `w-full` then spans the backdrop, which insets it by one gutter
15
+ * (`WINDOWED_OVERLAY_CLASS`, `utils/safeArea.ts`), so the window fills the screen and still reads
16
+ * as a window rather than a repaint of the app. A `Record` over the union, so a new bucket fails to
17
+ * compile until it is mapped.
18
18
  */
19
19
  export const RESULT_WINDOW_WIDTH_CLASS: Record<ResultWindowWidth, string> = {
20
20
  '3xl': 'max-w-3xl',
@@ -40,6 +40,7 @@ import {
40
40
  RESULT_WINDOW_WIDTH_CLASS,
41
41
  type ResultWindowWidth,
42
42
  } from '~/components/panels/ResultWindowShell.logic'
43
+ import { WINDOWED_OVERLAY_CLASS } from '~/utils/safeArea'
43
44
  import SectionLabel from '~/components/common/SectionLabel.vue'
44
45
 
45
46
  /** A pipeline step reference — passed by step-result windows to surface the shared
@@ -255,13 +256,17 @@ const chipClass = computed(() =>
255
256
  )
256
257
 
257
258
  const backdropClass = computed(() => [
258
- 'fixed inset-0 z-50 flex max-h-[100dvh] justify-center bg-app-950/70 backdrop-blur-sm',
259
- props.variant === 'centered' ? 'items-center p-4' : 'items-stretch',
259
+ WINDOWED_OVERLAY_CLASS,
260
+ 'z-50 justify-center bg-app-950/70 backdrop-blur-sm',
261
+ props.variant === 'centered' ? 'items-center' : 'items-stretch',
260
262
  ])
263
+ // The centered cap is the smaller of 90dvh and the backdrop's padded box: with a large inset (a
264
+ // Dynamic Island in standalone mode) the gutter alone can exceed 10% of the height, and a window
265
+ // sized to 90dvh would then push its top edge back under the status bar.
261
266
  const panelClass = computed(() => [
262
267
  'flex w-full flex-col overflow-hidden rounded-2xl border border-default bg-default shadow-2xl',
263
268
  RESULT_WINDOW_WIDTH_CLASS[props.width],
264
- props.variant === 'centered' ? 'max-h-[90dvh]' : 'm-4',
269
+ props.variant === 'centered' && 'max-h-[min(90dvh,100%)]',
265
270
  ])
266
271
  </script>
267
272
 
@@ -9,6 +9,7 @@ import { computed } from 'vue'
9
9
 
10
10
  const { t } = useI18n()
11
11
  const ui = useUiStore()
12
+ const { canManageSecrets } = useWorkspaceAccess()
12
13
 
13
14
  const open = computed({
14
15
  get: () => ui.aiProviderSetupOpen,
@@ -49,7 +50,12 @@ const routes = computed<Route[]>(() => [
49
50
  title: t('providers.onboarding.routes.keys.title'),
50
51
  body: t('providers.onboarding.routes.keys.body'),
51
52
  cta: t('providers.onboarding.routes.keys.cta'),
52
- onSelect: () => go(ui.openVendorCredentials),
53
+ // The default tab is the workspace's pooled subscriptions, a `secrets.manage` write. Anyone else
54
+ // lands on the direct keys, which open on their own "My keys" scope (see ApiKeysSection).
55
+ onSelect: () =>
56
+ go(() =>
57
+ canManageSecrets.value ? ui.openVendorCredentials() : ui.openVendorCredentials('direct'),
58
+ ),
53
59
  },
54
60
  {
55
61
  icon: 'i-lucide-route',
@@ -37,8 +37,27 @@ const { confirmAction, toastDone } = useConfirmAction()
37
37
  /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
38
38
  const isAccount = computed(() => !!props.accountId)
39
39
 
40
- /** Which store the form writes to: the shared workspace keys or the user's own. */
41
- const scope = ref<'workspace' | 'user'>('workspace')
40
+ /**
41
+ * Which store the form writes to: the shared workspace keys or the user's own. The DEFAULT follows
42
+ * the caller's grant on the active board: the workspace for whoever may write it (`secrets.manage`)
43
+ * and "My keys" for everyone else, so a first save lands instead of being refused. It is derived,
44
+ * not captured once, because this section stays mounted while the board (and so the grant)
45
+ * changes. A scope the user picks by hand wins until they move to another board.
46
+ */
47
+ const { canManageSecrets } = useWorkspaceAccess()
48
+ const pickedScope = ref<'workspace' | 'user' | null>(null)
49
+ const scope = computed<'workspace' | 'user'>({
50
+ get: () => pickedScope.value ?? (canManageSecrets.value ? 'workspace' : 'user'),
51
+ set: (value) => {
52
+ pickedScope.value = value
53
+ },
54
+ })
55
+ watch(
56
+ () => workspace.workspaceId,
57
+ () => {
58
+ pickedScope.value = null
59
+ },
60
+ )
42
61
 
43
62
  // "My keys" (user scope) are stored per-user, so they need a signed-in user. Block just
44
63
  // that scope when there's none (a deployment without sign-in); workspace/account keys are
@@ -24,6 +24,7 @@ import ProviderPreferenceEditor from '~/components/settings/ProviderPreferenceEd
24
24
  import { showOverrideField } from '~/utils/uiMode'
25
25
  import SectionLabel from '~/components/common/SectionLabel.vue'
26
26
  import IconButton from '~/components/common/IconButton.vue'
27
+ import { FULL_BLEED_TAKEOVER_CLASS } from '~/utils/safeArea'
27
28
 
28
29
  const { t } = useI18n()
29
30
  const ui = useUiStore()
@@ -300,7 +301,8 @@ async function save() {
300
301
  <Transition name="reader-fade">
301
302
  <div
302
303
  v-if="open"
303
- class="fixed inset-0 z-50 flex max-h-[100dvh] flex-col bg-app-950/96 backdrop-blur-sm"
304
+ class="z-50 flex-col bg-app-950/96 backdrop-blur-sm"
305
+ :class="FULL_BLEED_TAKEOVER_CLASS"
304
306
  role="dialog"
305
307
  aria-modal="true"
306
308
  >
@@ -27,6 +27,12 @@ const props = defineProps<{
27
27
 
28
28
  const { t } = useI18n()
29
29
  const github = useGitHubStore()
30
+ const { canManageIntegrations } = useWorkspaceAccess()
31
+
32
+ // Connecting is an `integrations.manage` write, and the connect-options read is gated the same way,
33
+ // so for anyone else the options come back empty. That empty list says nothing about the
34
+ // deployment: name the board admin instead of telling a member the operator configured nothing.
35
+ const adminRequired = computed(() => !canManageIntegrations.value)
30
36
 
31
37
  // Whether the deployment serves neither surface. Rendered as a statement rather than an empty
32
38
  // box: "nothing is configured" and "we couldn't read what is configured" both land here, and a
@@ -36,23 +42,33 @@ const nothingConfigured = computed(() => !github.canConnectGitHubApp && !github.
36
42
 
37
43
  <template>
38
44
  <div class="space-y-3">
39
- <template v-if="github.canConnectGitHubApp">
40
- <p v-if="props.appIntro" class="text-sm text-muted">{{ props.appIntro }}</p>
41
- <GitHubConnect />
42
- </template>
43
-
44
- <USeparator
45
- v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
46
- :label="t('vcs.connect.or')"
47
- />
48
-
49
- <GitLabConnect v-if="github.canConnectGitLabPat" />
50
-
51
45
  <p
52
- v-if="nothingConfigured"
46
+ v-if="adminRequired"
53
47
  class="rounded-md border border-dashed border-default px-3 py-3 text-sm text-muted"
48
+ data-testid="vcs-connect-admin-required"
54
49
  >
55
- {{ t('vcs.connect.noneConfigured') }}
50
+ {{ t('vcs.connect.adminRequired') }}
56
51
  </p>
52
+
53
+ <template v-else>
54
+ <template v-if="github.canConnectGitHubApp">
55
+ <p v-if="props.appIntro" class="text-sm text-muted">{{ props.appIntro }}</p>
56
+ <GitHubConnect />
57
+ </template>
58
+
59
+ <USeparator
60
+ v-if="github.canConnectGitHubApp && github.canConnectGitLabPat"
61
+ :label="t('vcs.connect.or')"
62
+ />
63
+
64
+ <GitLabConnect v-if="github.canConnectGitLabPat" />
65
+
66
+ <p
67
+ v-if="nothingConfigured"
68
+ class="rounded-md border border-dashed border-default px-3 py-3 text-sm text-muted"
69
+ >
70
+ {{ t('vcs.connect.noneConfigured') }}
71
+ </p>
72
+ </template>
57
73
  </div>
58
74
  </template>
@@ -0,0 +1,25 @@
1
+ import { computed } from 'vue'
2
+ import type { SetupActor } from '~/utils/setupAudience'
3
+
4
+ /**
5
+ * The caller as a setup prompt sees them: which app surface they chose, and the board and account
6
+ * grants that decide whether a "configure this" button would let them save. Every input is read
7
+ * from the source the nav gates read too (the role store, `useWorkspaceAccess`, and the accounts
8
+ * store's `isActiveAccountAdmin`), so a setup prompt and the sidebar cannot disagree about who the
9
+ * caller is.
10
+ */
11
+ export function useSetupActor() {
12
+ const access = useWorkspaceAccess()
13
+ const uiRole = useUiRoleStore()
14
+ const accounts = useAccountsStore()
15
+
16
+ const actor = computed<SetupActor>(() => ({
17
+ fullSurface: uiRole.fullSurface,
18
+ canManageIntegrations: access.canManageIntegrations.value,
19
+ canExecuteRuns: access.canExecuteRuns.value,
20
+ isAccountAdmin: accounts.isActiveAccountAdmin,
21
+ accountsEnabled: accounts.enabled,
22
+ }))
23
+
24
+ return { actor }
25
+ }
@@ -16,7 +16,7 @@ import type { WorkspacePermission, WorkspaceRole } from '~/types/domain'
16
16
  * SPA never hides an affordance the backend would have permitted.
17
17
  *
18
18
  * This is deliberately distinct from the ACCOUNT-scoped admin checks
19
- * (`accounts.activeAccount?.roles?.includes('admin')`), which stay account-scoped: this
19
+ * (`accounts.isActiveAccountAdmin`), which stay account-scoped: this
20
20
  * composable answers "what can you do inside THIS board", the account check answers "what
21
21
  * can you do to the tenant".
22
22
  */
@@ -75,7 +75,6 @@ export function createNavGates(): NavGates {
75
75
  providerConnections.isAvailable('runner-pool') ||
76
76
  providerConnections.isAvailable('environment'),
77
77
  )
78
- const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
79
78
 
80
79
  return {
81
80
  get canWriteBoard() {
@@ -105,7 +104,7 @@ export function createNavGates(): NavGates {
105
104
  return accounts.enabled
106
105
  },
107
106
  get isAccountAdmin() {
108
- return isAccountAdmin.value
107
+ return accounts.isActiveAccountAdmin
109
108
  },
110
109
  get advancedMode() {
111
110
  return uiMode.isAdvanced
@@ -35,6 +35,13 @@ export const useAccountsStore = defineStore(
35
35
  )
36
36
  /** Whether accounts exist (auth on); gates the switcher UI. */
37
37
  const enabled = computed(() => accounts.value.length > 0)
38
+ /**
39
+ * Whether the caller is an admin of the ACTIVE account. The one reading of it, so the nav gates,
40
+ * the setup prompts and the account panels cannot disagree about who the account admin is.
41
+ */
42
+ const isActiveAccountAdmin = computed(
43
+ () => activeAccount.value?.roles?.includes('admin') ?? false,
44
+ )
38
45
 
39
46
  /** Load the user's accounts and resolve the active one (persisted or first). */
40
47
  async function load() {
@@ -209,6 +216,7 @@ export const useAccountsStore = defineStore(
209
216
  activeAccountId,
210
217
  activeAccount,
211
218
  enabled,
219
+ isActiveAccountAdmin,
212
220
  ready,
213
221
  members,
214
222
  invitations,
@@ -1,6 +1,6 @@
1
1
  import { ref } from 'vue'
2
2
  import { applyInfraSetupTransition, isInfraSetupHealthStatus } from '@cat-factory/contracts'
3
- import type { InfraSetup, InfraSetupArea, InfraSetupStatus } from '~/types/domain'
3
+ import type { InfraSetup, InfraSetupArea, InfraSetupOwners, InfraSetupStatus } from '~/types/domain'
4
4
 
5
5
  // The workspace store's infra-setup slice: the per-area setup/health projection the setup banner
6
6
  // renders, and the live `infraSetup` event patch the reachability watcher pushes into it. Its own
@@ -32,8 +32,16 @@ export function createInfraSetupState() {
32
32
  */
33
33
  const infraSetupDetails = ref<Partial<Record<InfraSetupArea, string>>>({})
34
34
 
35
- function hydrate(next: InfraSetup | null | undefined) {
35
+ /**
36
+ * Who can close each area's gap (workspace admin, account admin or the operator), from the
37
+ * snapshot. Static for the life of a deployment, so the live event never touches it. Null on an
38
+ * older backend, which the banner reads as "owner unknown".
39
+ */
40
+ const infraSetupOwners = ref<InfraSetupOwners | null>(null)
41
+
42
+ function hydrate(next: InfraSetup | null | undefined, owners?: InfraSetupOwners | null) {
36
43
  infraSetup.value = next ?? null
44
+ infraSetupOwners.value = owners ?? null
37
45
  // The reasons belong to the live pushes this session observed, so an authoritative snapshot
38
46
  // supersedes them: keeping one would caption a freshly-read status with a stale probe.
39
47
  infraSetupDetails.value = {}
@@ -73,5 +81,5 @@ export function createInfraSetupState() {
73
81
  if (!isInfraSetupHealthStatus(status)) useUiStore().clearInfraSetupSessionDismissal(area)
74
82
  }
75
83
 
76
- return { infraSetup, infraSetupDetails, hydrate, patchInfraSetup }
84
+ return { infraSetup, infraSetupDetails, infraSetupOwners, hydrate, patchInfraSetup }
77
85
  }
@@ -59,6 +59,7 @@ export const useWorkspaceStore = defineStore(
59
59
  const {
60
60
  infraSetup,
61
61
  infraSetupDetails,
62
+ infraSetupOwners,
62
63
  hydrate: hydrateInfraSetup,
63
64
  patchInfraSetup,
64
65
  } = createInfraSetupState()
@@ -98,7 +99,7 @@ export const useWorkspaceStore = defineStore(
98
99
  accountSpend.value = snapshot.accountSpend ?? null
99
100
  userSpend.value = snapshot.userSpend ?? null
100
101
  budgetCaps.value = snapshot.budgetCaps ?? null
101
- hydrateInfraSetup(snapshot.infraSetup)
102
+ hydrateInfraSetup(snapshot.infraSetup, snapshot.infraSetupOwners)
102
103
  access.value = snapshot.access ?? null
103
104
  // Keep the board list in step (e.g. a freshly created board, or a rename). The
104
105
  // snapshot's `workspace` carries no `viewerRole` (that's a `GET /workspaces` list
@@ -241,6 +242,7 @@ export const useWorkspaceStore = defineStore(
241
242
  budgetCaps,
242
243
  infraSetup,
243
244
  infraSetupDetails,
245
+ infraSetupOwners,
244
246
  patchInfraSetup,
245
247
  access,
246
248
  init,
@@ -108,6 +108,8 @@ export type {
108
108
  InfraSetup,
109
109
  InfraSetupStatus,
110
110
  InfraSetupArea,
111
+ InfraSetupOwner,
112
+ InfraSetupOwners,
111
113
  ServiceFragmentDefaults,
112
114
  KaizenGradingStatus,
113
115
  KaizenGrading,
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { FULL_BLEED_TAKEOVER_CLASS, WINDOWED_OVERLAY_CLASS } from '~/utils/safeArea'
3
+
4
+ // A missing or misspelled side is silent on every screen without a notch, so the exact spellings
5
+ // are pinned here. That every full-screen surface binds one of these is
6
+ // `scripts/check-frontend-safe-area.mjs`'s job.
7
+ describe('full-screen overlay root classes', () => {
8
+ it('gives a takeover only the inset, so it stays edge to edge without one', () => {
9
+ expect(FULL_BLEED_TAKEOVER_CLASS.split(' ')).toEqual([
10
+ 'fixed',
11
+ 'inset-0',
12
+ 'flex',
13
+ 'max-h-[100dvh]',
14
+ 'pt-[env(safe-area-inset-top)]',
15
+ 'pr-[env(safe-area-inset-right)]',
16
+ 'pb-[env(safe-area-inset-bottom)]',
17
+ 'pl-[env(safe-area-inset-left)]',
18
+ ])
19
+ })
20
+
21
+ it('gives a windowed backdrop 1rem or the inset, whichever is larger, on every side', () => {
22
+ expect(WINDOWED_OVERLAY_CLASS.split(' ')).toEqual([
23
+ 'fixed',
24
+ 'inset-0',
25
+ 'flex',
26
+ 'max-h-[100dvh]',
27
+ 'pt-[max(1rem,env(safe-area-inset-top))]',
28
+ 'pr-[max(1rem,env(safe-area-inset-right))]',
29
+ 'pb-[max(1rem,env(safe-area-inset-bottom))]',
30
+ 'pl-[max(1rem,env(safe-area-inset-left))]',
31
+ ])
32
+ })
33
+ })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The root classes of the full-screen overlays, with the safe-area padding built in, so a phone's
3
+ * notch, rounded corners and home indicator never cover a header, a close button or an edge. The
4
+ * insets are physical, so the sides are too. They resolve to 0 unless the viewport meta carries
5
+ * `viewport-fit=cover` (`nuxt.config.ts`).
6
+ *
7
+ * A full-screen overlay binds one of these two constants on its root instead of spelling
8
+ * `fixed inset-0` itself, so it cannot drop the padding or the `100dvh` cap. The site adds only its
9
+ * stacking, direction and backdrop. `scripts/check-frontend-safe-area.mjs` refuses a full-screen
10
+ * `fixed` surface written anywhere else.
11
+ *
12
+ * Every class is spelled out literally: Tailwind generates only the class names it finds in the
13
+ * source text, so a name assembled at runtime would compile to nothing.
14
+ */
15
+
16
+ const FULL_SCREEN = 'fixed inset-0 flex max-h-[100dvh]'
17
+
18
+ /**
19
+ * A full-bleed takeover (`AgentStepDetail`, the model configuration takeover, the panels and the
20
+ * lightbox): only the inset, so on a screen without one the layout is exactly the edge-to-edge one
21
+ * it was designed as.
22
+ */
23
+ export const FULL_BLEED_TAKEOVER_CLASS = [
24
+ FULL_SCREEN,
25
+ 'pt-[env(safe-area-inset-top)]',
26
+ 'pr-[env(safe-area-inset-right)]',
27
+ 'pb-[env(safe-area-inset-bottom)]',
28
+ 'pl-[env(safe-area-inset-left)]',
29
+ ].join(' ')
30
+
31
+ /**
32
+ * A windowed overlay's backdrop (`ResultWindowShell`): 1rem, or the inset on that side when it is
33
+ * larger, so the window always floats clear of the screen edge and of the notch.
34
+ */
35
+ export const WINDOWED_OVERLAY_CLASS = [
36
+ FULL_SCREEN,
37
+ 'pt-[max(1rem,env(safe-area-inset-top))]',
38
+ 'pr-[max(1rem,env(safe-area-inset-right))]',
39
+ 'pb-[max(1rem,env(safe-area-inset-bottom))]',
40
+ 'pl-[max(1rem,env(safe-area-inset-left))]',
41
+ ].join(' ')
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { InfraSetup, InfraSetupOwners } from '~/types/domain'
3
+ import { blockingSetupNotice, canActOnSetup, type SetupActor } from './setupAudience'
4
+
5
+ const admin: SetupActor = {
6
+ fullSurface: true,
7
+ canManageIntegrations: true,
8
+ canExecuteRuns: true,
9
+ isAccountAdmin: false,
10
+ accountsEnabled: true,
11
+ }
12
+ const member: SetupActor = { ...admin, canManageIntegrations: false }
13
+ const viewer: SetupActor = { ...member, canExecuteRuns: false }
14
+ const designerAdmin: SetupActor = { ...admin, fullSurface: false }
15
+ const designerMember: SetupActor = { ...member, fullSurface: false }
16
+
17
+ const owners: InfraSetupOwners = {
18
+ agentExecutor: 'workspace_admin',
19
+ ephemeralEnvironments: 'workspace_admin',
20
+ binaryStorage: 'account_admin',
21
+ }
22
+
23
+ function status(patch: Partial<InfraSetup>): InfraSetup {
24
+ return {
25
+ agentExecutor: 'configured',
26
+ ephemeralEnvironments: 'configured',
27
+ binaryStorage: 'configured',
28
+ ...patch,
29
+ }
30
+ }
31
+
32
+ const noRunner = status({ agentExecutor: 'not_defined' })
33
+
34
+ describe('canActOnSetup', () => {
35
+ it('lets a workspace admin act on the workspace areas, and not a member', () => {
36
+ expect(canActOnSetup('workspace_admin', admin)).toBe(true)
37
+ expect(canActOnSetup('workspace_admin', member)).toBe(false)
38
+ })
39
+
40
+ it('keeps every prompt off the designer surface, whatever the permissions', () => {
41
+ expect(canActOnSetup('workspace_admin', designerAdmin)).toBe(false)
42
+ expect(canActOnSetup('account_admin', { ...designerAdmin, isAccountAdmin: true })).toBe(false)
43
+ })
44
+
45
+ it('gives an account-owned gap to the account admin, not to a workspace admin', () => {
46
+ expect(canActOnSetup('account_admin', admin)).toBe(false)
47
+ expect(canActOnSetup('account_admin', { ...member, isAccountAdmin: true })).toBe(true)
48
+ })
49
+
50
+ it('lets the single user act on an account-owned gap when there are no accounts', () => {
51
+ expect(canActOnSetup('account_admin', { ...admin, accountsEnabled: false })).toBe(true)
52
+ })
53
+
54
+ it('lets nobody act on an operator-owned gap', () => {
55
+ expect(canActOnSetup('operator', { ...admin, isAccountAdmin: true })).toBe(false)
56
+ })
57
+
58
+ it('falls back to the workspace admin for an absent owner or one this build does not know', () => {
59
+ // An older backend sends nothing; a newer one may send a value past the compiled union. Both
60
+ // read as the workspace admin, and neither throws inside the banner's render.
61
+ for (const owner of [undefined, 'board_owner']) {
62
+ expect(canActOnSetup(owner, admin)).toBe(true)
63
+ expect(canActOnSetup(owner, member)).toBe(false)
64
+ }
65
+ })
66
+ })
67
+
68
+ describe('blockingSetupNotice', () => {
69
+ it('tells a member that agents cannot run, and who can fix it', () => {
70
+ expect(blockingSetupNotice(noRunner, owners, member)).toEqual({
71
+ area: 'agentExecutor',
72
+ kind: 'setup',
73
+ remedy: 'workspace_admin',
74
+ })
75
+ })
76
+
77
+ it('reports an executor outage as an outage', () => {
78
+ expect(
79
+ blockingSetupNotice(status({ agentExecutor: 'unreachable' }), owners, member)?.kind,
80
+ ).toBe('outage')
81
+ })
82
+
83
+ it('says nothing to the admin, who gets the full card instead', () => {
84
+ expect(blockingSetupNotice(noRunner, owners, admin)).toBeNull()
85
+ })
86
+
87
+ it('says nothing to a viewer, whose work the gap does not stop', () => {
88
+ expect(blockingSetupNotice(noRunner, owners, viewer)).toBeNull()
89
+ })
90
+
91
+ it('tells an admin on the designer surface to switch surface, not to find an admin', () => {
92
+ expect(blockingSetupNotice(noRunner, owners, designerAdmin)?.remedy).toBe('full_surface')
93
+ // A designer without the grant is still pointed at the owner.
94
+ expect(blockingSetupNotice(noRunner, owners, designerMember)?.remedy).toBe('workspace_admin')
95
+ })
96
+
97
+ it('says nothing about gaps that only degrade some runs', () => {
98
+ const optional = status({ ephemeralEnvironments: 'not_defined', binaryStorage: 'not_defined' })
99
+ expect(blockingSetupNotice(optional, owners, member)).toBeNull()
100
+ })
101
+
102
+ it('says nothing when the executor is configured or not used here', () => {
103
+ expect(blockingSetupNotice(status({}), owners, member)).toBeNull()
104
+ expect(
105
+ blockingSetupNotice(status({ agentExecutor: 'not_applicable' }), owners, member),
106
+ ).toBeNull()
107
+ expect(blockingSetupNotice(null, owners, member)).toBeNull()
108
+ })
109
+
110
+ it('names the workspace admin when the backend reports no owner, or one it does not know', () => {
111
+ expect(blockingSetupNotice(noRunner, null, member)?.remedy).toBe('workspace_admin')
112
+ const unknown = { ...owners, agentExecutor: 'board_owner' } as unknown as InfraSetupOwners
113
+ expect(blockingSetupNotice(noRunner, unknown, member)?.remedy).toBe('workspace_admin')
114
+ })
115
+ })