@cat-factory/app 0.309.0 → 0.310.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 (133) hide show
  1. package/README.md +11 -2
  2. package/app/components/assistant/AssistantModal.vue +5 -4
  3. package/app/components/auth/BackendMisconfiguredScreen.vue +4 -3
  4. package/app/components/auth/LoginScreen.vue +40 -26
  5. package/app/components/auth/ResetPasswordScreen.vue +9 -4
  6. package/app/components/auth/UserMenu.vue +4 -3
  7. package/app/components/binaryCandidates/BinaryCandidatesWindow.vue +34 -23
  8. package/app/components/binaryOutput/StoredAssetView.vue +8 -6
  9. package/app/components/board/AddTaskModal.vue +16 -15
  10. package/app/components/board/AgentFailureCard.vue +12 -9
  11. package/app/components/board/AgentFailureHistory.vue +18 -9
  12. package/app/components/board/CreateInitiativeModal.vue +5 -4
  13. package/app/components/board/FailureDetail.vue +22 -12
  14. package/app/components/board/ReviewFrictionDialog.vue +4 -3
  15. package/app/components/board/nodes/BlockNode.vue +17 -15
  16. package/app/components/board/nodes/DecisionBadge.vue +4 -3
  17. package/app/components/board/nodes/FrameSwimlanes.vue +4 -3
  18. package/app/components/board/nodes/TaskCard.vue +12 -7
  19. package/app/components/board/nodes/TaskPipelineMini.vue +9 -7
  20. package/app/components/bootstrap/AdoptionReviewModal.vue +30 -21
  21. package/app/components/bootstrap/BootstrapModal.vue +3 -3
  22. package/app/components/brainstorm/BrainstormWindow.vue +5 -3
  23. package/app/components/bugFishing/BugFishingWindow.vue +49 -27
  24. package/app/components/clarity/ClarityReviewWindow.vue +5 -3
  25. package/app/components/common/CopyButton.vue +12 -11
  26. package/app/components/common/IconButton.vue +34 -9
  27. package/app/components/context/ContextAttachmentFields.vue +23 -12
  28. package/app/components/documents/ContextDocumentPicker.vue +12 -9
  29. package/app/components/documents/DocumentImportModal.vue +4 -3
  30. package/app/components/documents/DocumentOriginLink.vue +12 -9
  31. package/app/components/documents/DocumentSyncState.vue +3 -3
  32. package/app/components/documents/RepoContextDocPicker.vue +4 -3
  33. package/app/components/environments/EnvironmentStatusPanel.vue +4 -3
  34. package/app/components/followUp/FollowUpWindow.vue +9 -6
  35. package/app/components/forkDecision/ForkDecisionWindow.vue +71 -80
  36. package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -2
  37. package/app/components/foundational/FoundationalServiceRegistry.vue +7 -6
  38. package/app/components/foundational/FoundationalServiceSources.vue +7 -6
  39. package/app/components/fragments/FragmentLibraryManager.vue +5 -4
  40. package/app/components/fragments/FragmentSelector.vue +12 -9
  41. package/app/components/gates/GateFailingCheckList.vue +4 -3
  42. package/app/components/gates/GateResultView.vue +17 -12
  43. package/app/components/github/AddServiceFromRepoModal.vue +11 -7
  44. package/app/components/github/GitHubOnboarding.vue +7 -2
  45. package/app/components/github/GitHubPanel.vue +9 -6
  46. package/app/components/github/RepoTreeBrowser.vue +15 -12
  47. package/app/components/humanTest/HumanTestWindow.vue +13 -9
  48. package/app/components/initiative/InitiativePlanReview.vue +28 -18
  49. package/app/components/initiative/InitiativeTrackerWindow.vue +171 -124
  50. package/app/components/judge/JudgeResultView.vue +5 -3
  51. package/app/components/kaizen/KaizenPanel.vue +67 -53
  52. package/app/components/layout/AccountTeamSettings.vue +9 -9
  53. package/app/components/layout/AppearanceSwitcher.vue +4 -3
  54. package/app/components/layout/BoardSwitcher.vue +10 -8
  55. package/app/components/layout/BoardToolbar.vue +5 -4
  56. package/app/components/layout/CommandBar.vue +4 -3
  57. package/app/components/layout/DefaultTestEnvBanner.vue +4 -3
  58. package/app/components/layout/IntegrationsHub.vue +8 -6
  59. package/app/components/layout/LanguageSwitcher.vue +4 -3
  60. package/app/components/layout/ModelProvidersHub.vue +4 -3
  61. package/app/components/layout/NotificationsInbox.vue +40 -30
  62. package/app/components/layout/PersonalSetupModal.vue +4 -3
  63. package/app/components/layout/RolePrompt.vue +4 -3
  64. package/app/components/layout/SideBar.vue +4 -3
  65. package/app/components/layout/TranslationWarningBanner.vue +4 -3
  66. package/app/components/layout/UiModeSwitcher.vue +8 -6
  67. package/app/components/layout/UiRoleSwitcher.vue +8 -6
  68. package/app/components/layout/WorkspaceMembersSettings.vue +2 -2
  69. package/app/components/media/ArtifactLightbox.vue +69 -39
  70. package/app/components/media/ImageCompare.vue +69 -74
  71. package/app/components/observability/OutcomeFilterChips.vue +5 -4
  72. package/app/components/observability/RunFailureSummary.vue +8 -6
  73. package/app/components/observability/StepModelActivity.vue +5 -3
  74. package/app/components/observability/ToolCallList.vue +4 -2
  75. package/app/components/outcome/OutcomeSummaryWindow.vue +5 -4
  76. package/app/components/palettes/AgentPalette.vue +18 -14
  77. package/app/components/panels/AgentStepDetail.vue +30 -19
  78. package/app/components/panels/ObservabilityPanel.vue +149 -114
  79. package/app/components/panels/OperatorDashboardPanel.vue +99 -91
  80. package/app/components/panels/ReportsPanel.vue +64 -61
  81. package/app/components/panels/ResultWindowShell.vue +148 -134
  82. package/app/components/panels/StepContainerStatus.vue +9 -9
  83. package/app/components/panels/StepDelegatedStatus.vue +11 -9
  84. package/app/components/panels/StepRestartControl.vue +3 -2
  85. package/app/components/panels/inspector/EpicChildren.vue +4 -3
  86. package/app/components/panels/inspector/FrontendConfig.vue +3 -2
  87. package/app/components/panels/inspector/InspectorSection.vue +22 -17
  88. package/app/components/panels/inspector/ServiceConnections.vue +3 -2
  89. package/app/components/panels/inspector/ServiceTestConfig.vue +5 -4
  90. package/app/components/panels/inspector/TaskExecution.vue +5 -4
  91. package/app/components/panels/inspector/TaskRunSettings.vue +10 -6
  92. package/app/components/pipeline/EstimateThresholdFields.vue +13 -10
  93. package/app/components/pipeline/GateConfigFields.vue +14 -18
  94. package/app/components/pipeline/PipelineBuilder.vue +90 -77
  95. package/app/components/pipeline/PipelinePicker.vue +8 -6
  96. package/app/components/pipeline/PipelineProgress.vue +26 -21
  97. package/app/components/prReview/PrReviewWindow.vue +46 -25
  98. package/app/components/providers/ApiKeysSection.vue +4 -3
  99. package/app/components/ralph/RalphLoopResultView.vue +4 -3
  100. package/app/components/requirements/RequirementsReviewWindow.vue +10 -6
  101. package/app/components/riskPolicy/RiskPolicyPicker.vue +8 -6
  102. package/app/components/sandbox/SandboxPanel.vue +73 -54
  103. package/app/components/settings/CloudflareHandlerSection.vue +33 -30
  104. package/app/components/settings/ConsensusGroupsSection.vue +40 -30
  105. package/app/components/settings/InfraHandlersConfigurator.vue +16 -13
  106. package/app/components/settings/InfrastructureBackendPicker.vue +17 -17
  107. package/app/components/settings/IssueTrackerPanel.vue +21 -6
  108. package/app/components/settings/LocalModelEndpointsPanel.vue +3 -2
  109. package/app/components/settings/ModelConfigurationPanel.vue +9 -8
  110. package/app/components/settings/OpenRouterCatalogPanel.vue +4 -3
  111. package/app/components/settings/ProviderPreferenceEditor.vue +5 -6
  112. package/app/components/settings/RiskPolicyEditorRow.vue +3 -2
  113. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +10 -8
  114. package/app/components/skills/ReviewSkillQueue.vue +4 -3
  115. package/app/components/skills/SkillLibraryManager.vue +7 -6
  116. package/app/components/slack/SlackPanel.vue +9 -5
  117. package/app/components/spec/ServiceSpecWindow.vue +4 -3
  118. package/app/components/tasks/ContextIssuePicker.vue +12 -9
  119. package/app/components/tasks/TaskContextIssues.vue +4 -3
  120. package/app/components/testing/TestReportWindow.vue +14 -8
  121. package/app/components/theme/ThemeImportModal.vue +2 -2
  122. package/app/components/visualConfirm/VisualConfirmationWindow.vue +62 -35
  123. package/i18n/locales/de.json +7 -3
  124. package/i18n/locales/en.json +7 -3
  125. package/i18n/locales/es.json +7 -3
  126. package/i18n/locales/fr.json +7 -3
  127. package/i18n/locales/he.json +7 -3
  128. package/i18n/locales/it.json +7 -3
  129. package/i18n/locales/ja.json +7 -3
  130. package/i18n/locales/pl.json +7 -3
  131. package/i18n/locales/tr.json +7 -3
  132. package/i18n/locales/uk.json +7 -3
  133. package/package.json +2 -2
@@ -1,10 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
+ import type { TableColumn } from '@nuxt/ui'
4
5
  import type { PlatformObservabilityWindow } from '~/types/execution'
5
6
  import { formatMs } from '~/utils/observability'
6
7
  import { FAILURE_KIND_KEYS, isAgentFailureKind } from '~/utils/failureKinds'
7
8
  import SectionLabel from '~/components/common/SectionLabel.vue'
9
+ import IconButton from '~/components/common/IconButton.vue'
8
10
 
9
11
  // Deployment-level (platform-operator) observability dashboard: the aggregate health of the
10
12
  // active account's runs — outcome totals + success rate, a time-bucketed outcome trend, the
@@ -68,6 +70,19 @@ function barPct(count: number, max: number): number {
68
70
  // Share of a gate kind's runs the precheck satisfied outright, 0..1: the number the
69
71
  // precheck-before-escalate design exists to move. Null (not 0) when nothing settled, because
70
72
  // "no gates ran" is not "every gate needed a fixer".
73
+ // Column definitions rather than hand-written `<th>`s: the header typography is the table
74
+ // theme's, shared with every other table in the SPA.
75
+ // The count columns are flush right, so their headers are too.
76
+ const NUMERIC = { class: { th: 'text-end' } }
77
+ const gateColumns = computed<TableColumn<NonNullable<typeof view.value>['gates'][number]>[]>(() => [
78
+ { id: 'gate', header: t('platformObservability.gates.gate') },
79
+ { id: 'settled', header: t('platformObservability.gates.settled'), meta: NUMERIC },
80
+ { id: 'cleanPasses', header: t('platformObservability.gates.cleanPasses'), meta: NUMERIC },
81
+ { id: 'attempts', header: t('platformObservability.gates.attempts'), meta: NUMERIC },
82
+ { id: 'helperFailures', header: t('platformObservability.gates.helperFailures'), meta: NUMERIC },
83
+ { id: 'exhausted', header: t('platformObservability.gates.exhausted'), meta: NUMERIC },
84
+ ])
85
+
71
86
  function cleanRate(stat: { gates: number; cleanPasses: number }): number | null {
72
87
  return stat.gates > 0 ? stat.cleanPasses / stat.gates : null
73
88
  }
@@ -125,44 +140,44 @@ watch(
125
140
  <p v-if="accountName" class="truncate text-xs text-dimmed">{{ accountName }}</p>
126
141
  </div>
127
142
  <div class="ms-auto flex items-center gap-1.5">
128
- <div class="me-1 flex rounded-lg border border-default p-0.5 text-xs">
129
- <button
130
- v-for="opt in WINDOWS"
131
- :key="opt.value"
132
- class="rounded-md px-2.5 py-1 transition"
133
- :class="
134
- platform.window === opt.value
135
- ? 'bg-elevated text-app-100'
136
- : 'text-muted hover:text-default'
137
- "
138
- :data-testid="`operator-window-${opt.value}`"
139
- @click="setWindow(opt.value)"
140
- >
141
- {{ opt.label }}
142
- </button>
143
- </div>
144
- <button
145
- class="rounded-lg border border-default p-1.5 text-muted transition hover:text-default"
146
- :title="t('platformObservability.refresh')"
147
- :aria-label="t('platformObservability.refresh')"
143
+ <UTabs
144
+ :model-value="platform.window"
145
+ :items="WINDOWS"
146
+ :content="false"
147
+ size="xs"
148
+ class="me-1"
149
+ @update:model-value="setWindow($event as PlatformObservabilityWindow)"
150
+ >
151
+ <!-- UTabs renders its own triggers and forwards nothing from an item, so this
152
+ slot is the one place a stable per-window selector can live. -->
153
+ <template #default="{ item }">
154
+ <span :data-testid="`operator-window-${item.value}`">{{ item.label }}</span>
155
+ </template>
156
+ </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
+ }"
148
166
  data-testid="operator-refresh"
149
167
  @click="refresh"
150
- >
151
- <UIcon
152
- name="i-lucide-refresh-cw"
153
- class="h-4 w-4"
154
- :class="{ 'animate-spin': loading }"
155
- />
156
- </button>
157
- <button
158
- class="rounded-lg border border-default p-1.5 text-muted transition hover:text-default"
159
- :title="t('platformObservability.close')"
160
- :aria-label="t('platformObservability.close')"
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
+ }"
161
178
  data-testid="operator-close"
162
179
  @click="close"
163
- >
164
- <UIcon name="i-lucide-x" class="h-4 w-4" />
165
- </button>
180
+ />
166
181
  </div>
167
182
  </header>
168
183
 
@@ -172,12 +187,14 @@ watch(
172
187
  class="mx-auto max-w-2xl rounded-lg border border-app-error-800/60 bg-app-error-950/40 p-4 text-sm text-app-error-200"
173
188
  >
174
189
  <p>{{ error }}</p>
175
- <button
190
+ <UButton
191
+ color="neutral"
192
+ variant="ghost"
176
193
  class="mt-2 rounded-md border border-app-error-700 px-3 py-1 text-xs hover:bg-app-error-900/40"
177
194
  @click="refresh"
178
195
  >
179
196
  {{ t('platformObservability.retry') }}
180
- </button>
197
+ </UButton>
181
198
  </div>
182
199
 
183
200
  <div v-else-if="loading && !view" class="py-16 text-center text-sm text-muted">
@@ -327,62 +344,53 @@ watch(
327
344
  <p v-if="!view.gates.length" class="py-4 text-center text-xs text-dimmed">
328
345
  {{ t('platformObservability.gates.empty') }}
329
346
  </p>
330
- <table v-else class="w-full text-left text-xs" data-testid="operator-gates">
331
- <thead class="text-2xs uppercase tracking-wide text-dimmed">
332
- <tr>
333
- <th class="pb-2 pe-3 font-medium">
334
- {{ t('platformObservability.gates.gate') }}
335
- </th>
336
- <th class="pb-2 pe-3 text-end font-medium">
337
- {{ t('platformObservability.gates.settled') }}
338
- </th>
339
- <th class="pb-2 pe-3 text-end font-medium">
340
- {{ t('platformObservability.gates.cleanPasses') }}
341
- </th>
342
- <th class="pb-2 pe-3 text-end font-medium">
343
- {{ t('platformObservability.gates.attempts') }}
344
- </th>
345
- <th class="pb-2 pe-3 text-end font-medium">
346
- {{ t('platformObservability.gates.helperFailures') }}
347
- </th>
348
- <th class="pb-2 text-end font-medium">
349
- {{ t('platformObservability.gates.exhausted') }}
350
- </th>
351
- </tr>
352
- </thead>
353
- <tbody class="text-toned">
354
- <tr
355
- v-for="g in view.gates"
356
- :key="g.gateKind"
357
- class="border-t border-default/70"
347
+ <UTable
348
+ v-else
349
+ :data="view.gates"
350
+ :columns="gateColumns"
351
+ :ui="{
352
+ base: 'text-xs',
353
+ th: 'ps-0 pe-3 py-2',
354
+ td: 'ps-0 pe-3 py-2 text-xs text-toned whitespace-normal',
355
+ }"
356
+ data-testid="operator-gates"
357
+ >
358
+ <template #gate-cell="{ row }">
359
+ <span class="font-medium text-default">{{ row.original.gateKind }}</span>
360
+ <span v-if="row.original.helperKind" class="ms-1.5 text-dimmed"
361
+ >&rarr; {{ row.original.helperKind }}</span
358
362
  >
359
- <td class="py-2 pe-3">
360
- <span class="font-medium text-default">{{ g.gateKind }}</span>
361
- <span v-if="g.helperKind" class="ms-1.5 text-dimmed"
362
- >&rarr; {{ g.helperKind }}</span
363
- >
364
- </td>
365
- <td class="py-2 pe-3 text-end tabular-nums">{{ g.gates }}</td>
366
- <td class="py-2 pe-3 text-end tabular-nums">
367
- <span class="text-app-success-400">{{ g.cleanPasses }}</span>
368
- <span v-if="cleanRate(g) !== null" class="ms-1 text-dimmed"
369
- >({{ n(cleanRate(g) ?? 0, 'percent') }})</span
370
- >
371
- </td>
372
- <td class="py-2 pe-3 text-end tabular-nums">{{ g.attempts }}</td>
373
- <td class="py-2 pe-3 text-end tabular-nums">
374
- <span :class="g.helperFailures > 0 ? 'text-app-warning-400' : ''">{{
375
- g.helperFailures
376
- }}</span>
377
- </td>
378
- <td class="py-2 text-end tabular-nums">
379
- <span :class="g.exhausted > 0 ? 'text-app-error-400' : ''">{{
380
- g.exhausted
381
- }}</span>
382
- </td>
383
- </tr>
384
- </tbody>
385
- </table>
363
+ </template>
364
+ <template #settled-cell="{ row }">
365
+ <span class="block text-end tabular-nums">{{ row.original.gates }}</span>
366
+ </template>
367
+ <template #cleanPasses-cell="{ row }">
368
+ <span class="block text-end tabular-nums">
369
+ <span class="text-app-success-400">{{ row.original.cleanPasses }}</span>
370
+ <span v-if="cleanRate(row.original) !== null" class="ms-1 text-dimmed"
371
+ >({{ n(cleanRate(row.original) ?? 0, 'percent') }})</span
372
+ >
373
+ </span>
374
+ </template>
375
+ <template #attempts-cell="{ row }">
376
+ <span class="block text-end tabular-nums">{{ row.original.attempts }}</span>
377
+ </template>
378
+ <template #helperFailures-cell="{ row }">
379
+ <span class="block text-end tabular-nums">
380
+ <span
381
+ :class="row.original.helperFailures > 0 ? 'text-app-warning-400' : ''"
382
+ >{{ row.original.helperFailures }}</span
383
+ >
384
+ </span>
385
+ </template>
386
+ <template #exhausted-cell="{ row }">
387
+ <span class="block text-end tabular-nums">
388
+ <span :class="row.original.exhausted > 0 ? 'text-app-error-400' : ''">{{
389
+ row.original.exhausted
390
+ }}</span>
391
+ </span>
392
+ </template>
393
+ </UTable>
386
394
  <p class="mt-3 text-2xs leading-relaxed text-dimmed">
387
395
  {{ t('platformObservability.gates.hint') }}
388
396
  </p>
@@ -20,6 +20,7 @@ import {
20
20
  } from './ReportsPanel.logic'
21
21
  import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
22
22
  import SectionLabel from '~/components/common/SectionLabel.vue'
23
+ import IconButton from '~/components/common/IconButton.vue'
23
24
 
24
25
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
25
26
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -137,6 +138,14 @@ const maxRuns = computed(() => maxOf(activityByDimension.value, (row) => row.run
137
138
 
138
139
  /** Boards the filter offers — the active account's, since the report is account-scoped. */
139
140
  const boards = computed(() => workspace.accountWorkspaces)
141
+ // "Every board" is a NAMED sentinel, not an empty string: an empty string is what a Select
142
+ // reserves for "nothing selected", and Reka throws on an item that carries one. The store holds
143
+ // the same state as null, so the two are mapped at the boundary below.
144
+ const ALL_BOARDS = 'all'
145
+ const boardFilterItems = computed(() => [
146
+ { label: t('reports.filter.allBoards'), value: ALL_BOARDS },
147
+ ...boards.value.map((board) => ({ label: board.name, value: board.id })),
148
+ ])
140
149
 
141
150
  function trendTooltip(point: { start: number; meteredCost: number; subscriptionCost: number }) {
142
151
  return `${d(new Date(point.start), 'short')} · ${t('reports.legend.metered')} ${money(point.meteredCost)} · ${t('reports.legend.subscription')} ${money(point.subscriptionCost)}`
@@ -206,55 +215,52 @@ watch(
206
215
  </div>
207
216
  <!-- Filters in ONE row above the charts: window, then board scope. -->
208
217
  <div class="ms-auto flex flex-wrap items-center gap-1.5">
209
- <select
210
- class="rounded-lg border border-default bg-default px-2.5 py-1.5 text-xs text-default"
211
- :value="reports.workspaceFilter ?? ''"
218
+ <USelect
219
+ :model-value="reports.workspaceFilter ?? ALL_BOARDS"
220
+ :items="boardFilterItems"
221
+ size="xs"
212
222
  :aria-label="t('reports.filter.board')"
213
223
  data-testid="reports-board-filter"
214
- @change="
215
- reports.setWorkspaceFilter(($event.target as HTMLSelectElement).value || null)
224
+ @update:model-value="
225
+ reports.setWorkspaceFilter($event === ALL_BOARDS ? null : String($event))
216
226
  "
227
+ />
228
+ <UTabs
229
+ :model-value="reports.window"
230
+ :items="WINDOWS"
231
+ :content="false"
232
+ size="xs"
233
+ class="me-1"
234
+ @update:model-value="reports.setWindow($event as ReportWindow)"
217
235
  >
218
- <option value="">{{ t('reports.filter.allBoards') }}</option>
219
- <option v-for="board in boards" :key="board.id" :value="board.id">
220
- {{ board.name }}
221
- </option>
222
- </select>
223
- <div class="me-1 flex rounded-lg border border-default p-0.5 text-xs">
224
- <button
225
- v-for="opt in WINDOWS"
226
- :key="opt.value"
227
- class="rounded-md px-2.5 py-1 transition"
228
- :class="
229
- reports.window === opt.value
230
- ? 'bg-elevated text-app-100'
231
- : 'text-muted hover:text-default'
232
- "
233
- :data-testid="`reports-window-${opt.value}`"
234
- @click="reports.setWindow(opt.value)"
235
- >
236
- {{ opt.label }}
237
- </button>
238
- </div>
239
- <button
240
- class="rounded-lg border border-default p-1.5 text-muted transition hover:text-default"
241
- :aria-label="t('reports.refresh')"
242
- :title="t('reports.refresh')"
236
+ <!-- UTabs renders its own triggers and forwards nothing from an item, so this
237
+ slot is the one place a stable per-window selector can live. -->
238
+ <template #default="{ item }">
239
+ <span :data-testid="`reports-window-${item.value}`">{{ item.label }}</span>
240
+ </template>
241
+ </UTabs>
242
+ <IconButton
243
+ color="neutral"
244
+ variant="ghost"
245
+ icon="i-lucide-refresh-cw"
246
+ :label="t('reports.refresh')"
247
+ :ui="{
248
+ base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
249
+ leadingIcon: loading ? 'h-4 w-4 animate-spin' : 'h-4 w-4',
250
+ }"
243
251
  @click="refresh"
244
- >
245
- <UIcon
246
- name="i-lucide-refresh-cw"
247
- class="h-4 w-4"
248
- :class="{ 'animate-spin': loading }"
249
- />
250
- </button>
251
- <button
252
- class="rounded-lg border border-default p-1.5 text-muted transition hover:text-default"
253
- :aria-label="t('common.close')"
252
+ />
253
+ <IconButton
254
+ color="neutral"
255
+ variant="ghost"
256
+ icon="i-lucide-x"
257
+ :label="t('common.close')"
258
+ :ui="{
259
+ base: 'rounded-lg border border-default p-1.5 text-muted transition hover:text-default',
260
+ leadingIcon: 'h-4 w-4',
261
+ }"
254
262
  @click="close"
255
- >
256
- <UIcon name="i-lucide-x" class="h-4 w-4" />
257
- </button>
263
+ />
258
264
  </div>
259
265
  </header>
260
266
 
@@ -265,12 +271,14 @@ watch(
265
271
  >
266
272
  <p>{{ t('reports.error') }}</p>
267
273
  <p v-if="error" class="mt-1 text-xs text-app-error-300/80">{{ error }}</p>
268
- <button
274
+ <UButton
275
+ color="neutral"
276
+ variant="ghost"
269
277
  class="mt-2 rounded-md border border-app-error-700 px-3 py-1 text-xs hover:bg-app-error-900/40"
270
278
  @click="refresh"
271
279
  >
272
280
  {{ t('reports.retry') }}
273
- </button>
281
+ </UButton>
274
282
  </div>
275
283
 
276
284
  <div v-else-if="loading && !view" class="py-16 text-center text-sm text-muted">
@@ -466,22 +474,17 @@ watch(
466
474
  <SectionLabel as="h2">
467
475
  {{ t('reports.breakdown.title') }}
468
476
  </SectionLabel>
469
- <div class="flex rounded-lg border border-default p-0.5 text-xs">
470
- <button
471
- v-for="opt in DIMENSIONS"
472
- :key="opt.value"
473
- class="rounded-md px-2.5 py-1 transition"
474
- :class="
475
- dimension === opt.value
476
- ? 'bg-elevated text-app-100'
477
- : 'text-muted hover:text-default'
478
- "
479
- :data-testid="`reports-dimension-${opt.value}`"
480
- @click="dimension = opt.value"
481
- >
482
- {{ opt.label }}
483
- </button>
484
- </div>
477
+ <UTabs
478
+ :model-value="dimension"
479
+ :items="DIMENSIONS"
480
+ :content="false"
481
+ size="xs"
482
+ @update:model-value="dimension = $event as ReportActivityDimension"
483
+ >
484
+ <template #default="{ item }">
485
+ <span :data-testid="`reports-dimension-${item.value}`">{{ item.label }}</span>
486
+ </template>
487
+ </UTabs>
485
488
  </div>
486
489
  <div class="grid gap-6 md:grid-cols-2">
487
490
  <div>