@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
@@ -30,6 +30,7 @@ import {
30
30
  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
+ import IconButton from '~/components/common/IconButton.vue'
33
34
 
34
35
  // Detail overlay for a single pipeline step. Opened by clicking an agent in the
35
36
  // inspector list (TaskExecution) or the focus-view pipeline (PipelineProgress) via
@@ -408,7 +409,9 @@ async function copyOutput() {
408
409
  </SectionLabel>
409
410
  </div>
410
411
  <nav class="flex-1 space-y-0.5 overflow-auto px-2 py-3">
411
- <button
412
+ <UButton
413
+ color="neutral"
414
+ variant="ghost"
412
415
  class="block w-full truncate rounded-md px-2 py-1 text-start text-sm transition"
413
416
  :class="
414
417
  activeId === 'step-details'
@@ -418,8 +421,10 @@ async function copyOutput() {
418
421
  @click="goTo('step-details')"
419
422
  >
420
423
  {{ t('panels.stepDetail.details') }}
421
- </button>
422
- <button
424
+ </UButton>
425
+ <UButton
426
+ color="neutral"
427
+ variant="ghost"
423
428
  v-for="s in tocSections"
424
429
  :key="s.id"
425
430
  class="block w-full truncate rounded-md px-2 py-1 text-start text-sm transition"
@@ -433,7 +438,7 @@ async function copyOutput() {
433
438
  @click="goTo(s.id)"
434
439
  >
435
440
  {{ s.title }}
436
- </button>
441
+ </UButton>
437
442
  </nav>
438
443
  </aside>
439
444
 
@@ -471,24 +476,24 @@ async function copyOutput() {
471
476
  <UIcon name="i-lucide-alert-triangle" class="me-1 h-3 w-3" />
472
477
  {{ t('panels.stepDetail.decisionRequired') }}
473
478
  </UBadge>
474
- <UButton
479
+ <IconButton
475
480
  v-if="outline.sections.length"
476
481
  :icon="allCollapsed ? 'i-lucide-unfold-vertical' : 'i-lucide-fold-vertical'"
477
482
  color="neutral"
478
483
  variant="ghost"
479
484
  size="sm"
480
- :title="
485
+ :label="
481
486
  allCollapsed ? t('panels.stepDetail.expandAll') : t('panels.stepDetail.collapseAll')
482
487
  "
483
488
  @click="setAll(!allCollapsed)"
484
489
  />
485
- <UButton
490
+ <IconButton
486
491
  v-if="hasOutput"
487
492
  icon="i-lucide-copy"
488
493
  color="neutral"
489
494
  variant="ghost"
490
495
  size="sm"
491
- :title="t('panels.stepDetail.copyRawOutput')"
496
+ :label="t('panels.stepDetail.copyRawOutput')"
492
497
  @click="copyOutput"
493
498
  />
494
499
  <!-- Restart the pipeline from this step (shared two-click confirm; resetting
@@ -500,12 +505,12 @@ async function copyOutput() {
500
505
  :step-index="ctx?.stepIndex ?? null"
501
506
  @restarted="close"
502
507
  />
503
- <UButton
508
+ <IconButton
504
509
  icon="i-lucide-x"
505
510
  color="neutral"
506
511
  variant="ghost"
507
512
  size="sm"
508
- :title="t('panels.stepDetail.closeEsc')"
513
+ :label="t('panels.stepDetail.closeEsc')"
509
514
  @click="close"
510
515
  />
511
516
  </div>
@@ -782,9 +787,11 @@ async function copyOutput() {
782
787
  :ref="(el) => (sectionEls[s.id] = el as HTMLElement | null)"
783
788
  class="scroll-mt-4"
784
789
  >
785
- <button
790
+ <UButton
791
+ color="neutral"
792
+ variant="ghost"
786
793
  v-if="s.depth > 0"
787
- class="group flex w-full items-center gap-2 rounded-md py-1 text-start transition hover:text-highlighted"
794
+ class="group flex w-full items-center gap-2 rounded-md px-0 py-1 text-start transition hover:bg-transparent hover:text-highlighted"
788
795
  @click="toggle(s.id)"
789
796
  >
790
797
  <UIcon
@@ -797,7 +804,7 @@ async function copyOutput() {
797
804
  :class="s.depth <= 1 ? 'text-lg' : s.depth === 2 ? 'text-base' : 'text-sm'"
798
805
  v-html="s.titleHtml"
799
806
  />
800
- </button>
807
+ </UButton>
801
808
  <!-- eslint-disable-next-line vue/no-v-html -->
802
809
  <div
803
810
  v-show="!collapsed[s.id]"
@@ -914,13 +921,17 @@ async function copyOutput() {
914
921
  <SectionLabel>
915
922
  {{ t('panels.stepDetail.commentN', { number: idx + 1 }) }}
916
923
  </SectionLabel>
917
- <button
918
- class="text-dimmed transition hover:text-app-error-400"
919
- :title="t('panels.stepDetail.removeComment')"
924
+ <IconButton
925
+ color="neutral"
926
+ variant="ghost"
927
+ icon="i-lucide-x"
928
+ :label="t('panels.stepDetail.removeComment')"
929
+ :ui="{
930
+ base: 'p-0 text-dimmed transition hover:bg-transparent hover:text-app-error-400',
931
+ leadingIcon: 'h-3.5 w-3.5',
932
+ }"
920
933
  @click="removeComment(idx)"
921
- >
922
- <UIcon name="i-lucide-x" class="h-3.5 w-3.5" />
923
- </button>
934
+ />
924
935
  </div>
925
936
  <pre
926
937
  class="mb-1 max-h-20 overflow-auto whitespace-pre-wrap rounded-sm bg-app-950/50 p-1.5 text-3xs text-muted"
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { computed, nextTick, reactive, ref, watch } from 'vue'
3
3
  import { onKeyStroke } from '@vueuse/core'
4
+ import type { TableColumn } from '@nuxt/ui'
4
5
  import { isLlmWarningFinishReason } from '@cat-factory/contracts'
5
6
  import type {
6
7
  AgentContextSnapshot,
@@ -30,6 +31,7 @@ import OutcomeFilterChips from '~/components/observability/OutcomeFilterChips.vu
30
31
  import RunFailureSummary from '~/components/observability/RunFailureSummary.vue'
31
32
  import ToolCallList from '~/components/observability/ToolCallList.vue'
32
33
  import SectionLabel from '~/components/common/SectionLabel.vue'
34
+ import IconButton from '~/components/common/IconButton.vue'
33
35
 
34
36
  /** No run selected: the same empty, NOT-truncated trajectory the store answers with. */
35
37
  const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
@@ -166,6 +168,22 @@ function openToolsView() {
166
168
  ensureTrajectoryLoaded()
167
169
  }
168
170
 
171
+ const viewTabs = computed(() => [
172
+ { value: 'calls', label: t('observability.modelActivity') },
173
+ { value: 'tools', label: t('observability.toolCalls.title') },
174
+ { value: 'context', label: t('observability.providedContext') },
175
+ { value: 'search', label: t('observability.webSearch') },
176
+ ])
177
+
178
+ /** The tools view has a side effect (lazy trajectory load), so selection routes through here. */
179
+ function selectView(next: string) {
180
+ if (next === 'tools') {
181
+ openToolsView()
182
+ return
183
+ }
184
+ view.value = next as typeof view.value
185
+ }
186
+
169
187
  /**
170
188
  * Load the trajectory the first time it is actually looked at.
171
189
  *
@@ -411,6 +429,23 @@ const costCurrency = computed(
411
429
  const showCost = computed(
412
430
  () => !!costCurrency.value && phaseRows.value.some((p) => p.costEstimate != null),
413
431
  )
432
+ // Column definitions rather than hand-written `<th>`s. The cost column stays conditional on the
433
+ // same signal: a deployment with no rate table must not be shown a column of em dashes.
434
+ const phaseColumns = computed<TableColumn<(typeof phaseRows.value)[number]>[]>(() => [
435
+ { id: 'phase' },
436
+ { id: 'turns' },
437
+ { id: 'tokens' },
438
+ ...(showCost.value ? [{ id: 'cost' } as TableColumn<(typeof phaseRows.value)[number]>] : []),
439
+ // `aria-sort` needs BOTH halves: UTable asks `enableSorting && column.getCanSort()`, and
440
+ // `getCanSort()` is false on a column with no accessor, so `enableSorting` alone emitted
441
+ // nothing and the header silently stopped stating the order. The rows arrive in this order
442
+ // from the rollup, so `manualSorting` below keeps TanStack from re-deriving it: the
443
+ // declaration MARKS the order, it does not produce it, and nothing here offers a sort control.
444
+ { id: 'carryCost', accessorKey: 'carryCostTokens', enableSorting: true },
445
+ ])
446
+ /** The order the rollup already returns, stated so the header can say which column it is. */
447
+ const PHASE_SORTING = [{ id: 'carryCost', desc: true }]
448
+ const PHASE_SORTING_OPTIONS = { manualSorting: true }
414
449
  /**
415
450
  * The run's estimated cost, folded from the same SQL rollup the phase table shows — NOT from
416
451
  * the capped call list the token totals beside it use, which would silently under-report a run
@@ -521,44 +556,17 @@ function exportJson() {
521
556
  </p>
522
557
  </div>
523
558
  <div class="ms-auto flex items-center gap-1.5">
524
- <div class="me-1 flex rounded-lg border border-default p-0.5 text-xs">
525
- <button
526
- class="rounded-md px-2.5 py-1 transition"
527
- :class="
528
- view === 'calls' ? 'bg-elevated text-app-100' : 'text-muted hover:text-default'
529
- "
530
- @click="view = 'calls'"
531
- >
532
- {{ t('observability.modelActivity') }}
533
- </button>
534
- <button
535
- class="rounded-md px-2.5 py-1 transition"
536
- :class="
537
- view === 'tools' ? 'bg-elevated text-app-100' : 'text-muted hover:text-default'
538
- "
539
- @click="openToolsView()"
540
- >
541
- {{ t('observability.toolCalls.title') }}
542
- </button>
543
- <button
544
- class="rounded-md px-2.5 py-1 transition"
545
- :class="
546
- view === 'context' ? 'bg-elevated text-app-100' : 'text-muted hover:text-default'
547
- "
548
- @click="view = 'context'"
549
- >
550
- {{ t('observability.providedContext') }}
551
- </button>
552
- <button
553
- class="rounded-md px-2.5 py-1 transition"
554
- :class="
555
- view === 'search' ? 'bg-elevated text-app-100' : 'text-muted hover:text-default'
556
- "
557
- @click="view = 'search'"
558
- >
559
- {{ t('observability.webSearch') }}
560
- </button>
561
- </div>
559
+ <!-- Four VIEWS of the same run, which is what tabs mean. The trajectory is loaded
560
+ lazily the first time the tools view is opened, so the change is watched rather
561
+ than bound straight to `view`. -->
562
+ <UTabs
563
+ :model-value="view"
564
+ :items="viewTabs"
565
+ :content="false"
566
+ size="xs"
567
+ class="me-1"
568
+ @update:model-value="selectView(String($event))"
569
+ />
562
570
  <UButton
563
571
  v-if="view === 'calls'"
564
572
  icon="i-lucide-download"
@@ -572,12 +580,12 @@ function exportJson() {
572
580
  >
573
581
  {{ t('observability.exportJson') }}
574
582
  </UButton>
575
- <UButton
583
+ <IconButton
576
584
  icon="i-lucide-x"
577
585
  color="neutral"
578
586
  variant="ghost"
579
587
  size="sm"
580
- :title="t('observability.closeEsc')"
588
+ :label="t('observability.closeEsc')"
581
589
  @click="close"
582
590
  />
583
591
  </div>
@@ -732,76 +740,99 @@ function exportJson() {
732
740
  {{ t('observability.phase.noRollup') }}
733
741
  </p>
734
742
  <div v-else class="mt-3 overflow-x-auto">
735
- <table class="w-full min-w-[32rem] text-xs">
736
- <thead>
737
- <tr class="text-2xs uppercase tracking-wide text-dimmed">
738
- <th class="py-1 pe-3 text-start font-normal">
739
- {{ t('observability.phase.columns.phase') }}
740
- </th>
741
- <th class="py-1 px-3 text-end font-normal">
742
- {{ t('observability.phase.columns.turns') }}
743
- </th>
744
- <th class="py-1 px-3 text-end font-normal">
745
- {{ t('observability.phase.columns.tokensInOut') }}
746
- </th>
747
- <th v-if="showCost" class="py-1 px-3 text-end font-normal">
748
- <span :title="t('observability.phase.costHint')">
749
- {{ t('observability.phase.columns.cost') }}
750
- </span>
751
- </th>
752
- <!-- The sort key, MARKED as one. Rows lead with carry cost rather than
753
- with tokens, and the two orders genuinely differ: a phase that runs
754
- late carries almost nothing however much it spent (nothing after it
755
- re-sends its context). Leaving that implicit invites reading row 1
756
- as "the phase that burned the most", which is the neighbouring
757
- column. -->
758
- <th aria-sort="descending" class="py-1 ps-3 text-end font-normal">
759
- <span :title="t('observability.phase.carryCostHint')">
760
- {{ t('observability.phase.columns.carryCost') }} ↓
761
- </span>
762
- </th>
763
- </tr>
764
- </thead>
765
- <tbody>
766
- <tr v-for="p in phaseRows" :key="p.phase" class="border-t border-default/70">
767
- <td class="py-1.5 pe-3 text-default">
768
- <span :class="p.phase ? '' : 'text-muted italic'">
769
- {{ phaseLabel(p.phase) }}
770
- </span>
771
- <span v-if="!p.phase" class="ms-1.5 text-2xs text-app-600">
772
- {{ t('observability.phase.unattributedHint') }}
773
- </span>
774
- <UBadge
775
- v-if="p.errors"
776
- color="error"
777
- variant="subtle"
778
- size="sm"
779
- class="ms-2"
780
- >
781
- {{ t('observability.metricsBar.errors', { count: p.errors }, p.errors) }}
782
- </UBadge>
783
- </td>
784
- <td class="py-1.5 px-3 text-end tabular-nums text-toned">
785
- {{ p.calls }}
786
- </td>
787
- <td class="py-1.5 px-3 text-end tabular-nums text-toned">
788
- {{ formatTokens(totalInputTokens(p)) }}↑
789
- {{ formatTokens(p.completionTokens) }}↓
790
- </td>
791
- <td v-if="showCost" class="py-1.5 px-3 text-end tabular-nums text-toned">
792
- <!-- An em dash, not 0: this phase's model had no rate, and a zero here
793
- would read as a phase that cost nothing. -->
794
- {{ formatCost(p.costEstimate, costCurrency) ?? '—' }}
795
- </td>
796
- <td class="py-1.5 ps-3 text-end tabular-nums text-toned">
797
- {{ formatTokens(p.carryCostTokens) }}
798
- <span v-if="carryShare(p.carryCostTokens) !== null" class="text-app-600">
799
- · {{ carryShare(p.carryCostTokens) }}%
800
- </span>
801
- </td>
802
- </tr>
803
- </tbody>
804
- </table>
743
+ <UTable
744
+ :data="phaseRows"
745
+ :columns="phaseColumns"
746
+ :sorting="PHASE_SORTING"
747
+ :sorting-options="PHASE_SORTING_OPTIONS"
748
+ :ui="{
749
+ base: 'min-w-[32rem] text-xs',
750
+ th: 'px-3 py-1.5',
751
+ td: 'px-3 py-1.5 text-xs whitespace-normal',
752
+ }"
753
+ >
754
+ <template #phase-header>
755
+ {{ t('observability.phase.columns.phase') }}
756
+ </template>
757
+ <template #turns-header>
758
+ <span class="block text-end">
759
+ {{ t('observability.phase.columns.turns') }}
760
+ </span>
761
+ </template>
762
+ <template #tokens-header>
763
+ <span class="block text-end">
764
+ {{ t('observability.phase.columns.tokensInOut') }}
765
+ </span>
766
+ </template>
767
+ <template #cost-header>
768
+ <span class="block text-end" :title="t('observability.phase.costHint')">
769
+ {{ t('observability.phase.columns.cost') }}
770
+ </span>
771
+ </template>
772
+ <!-- The sort key, MARKED as one. Rows lead with carry cost rather than
773
+ with tokens, and the two orders genuinely differ: a phase that runs
774
+ late carries almost nothing however much it spent (nothing after it
775
+ re-sends its context). Leaving that implicit invites reading row 1
776
+ as "the phase that burned the most", which is the neighbouring
777
+ column. -->
778
+ <template #carryCost-header>
779
+ <span class="block text-end" :title="t('observability.phase.carryCostHint')">
780
+ {{ t('observability.phase.columns.carryCost') }} &darr;
781
+ </span>
782
+ </template>
783
+ <template #phase-cell="{ row }">
784
+ <span :class="row.original.phase ? '' : 'text-muted italic'">
785
+ {{ phaseLabel(row.original.phase) }}
786
+ </span>
787
+ <span v-if="!row.original.phase" class="ms-1.5 text-2xs text-app-600">
788
+ {{ t('observability.phase.unattributedHint') }}
789
+ </span>
790
+ <UBadge
791
+ v-if="row.original.errors"
792
+ color="error"
793
+ variant="subtle"
794
+ size="sm"
795
+ class="ms-2"
796
+ >
797
+ {{
798
+ t(
799
+ 'observability.metricsBar.errors',
800
+ { count: row.original.errors },
801
+ row.original.errors,
802
+ )
803
+ }}
804
+ </UBadge>
805
+ </template>
806
+ <template #turns-cell="{ row }">
807
+ <span class="block text-end tabular-nums text-toned">{{
808
+ row.original.calls
809
+ }}</span>
810
+ </template>
811
+ <template #tokens-cell="{ row }">
812
+ <span class="block text-end tabular-nums text-toned">
813
+ {{ formatTokens(totalInputTokens(row.original)) }}&uarr;
814
+ {{ formatTokens(row.original.completionTokens) }}&darr;
815
+ </span>
816
+ </template>
817
+ <!-- An em dash, not 0: this phase's model had no rate, and a zero here
818
+ would read as a phase that cost nothing. -->
819
+ <template #cost-cell="{ row }">
820
+ <span class="block text-end tabular-nums text-toned">
821
+ {{ formatCost(row.original.costEstimate, costCurrency) ?? '—' }}
822
+ </span>
823
+ </template>
824
+ <template #carryCost-cell="{ row }">
825
+ <span class="block text-end tabular-nums text-toned">
826
+ {{ formatTokens(row.original.carryCostTokens) }}
827
+ <span
828
+ v-if="carryShare(row.original.carryCostTokens) !== null"
829
+ class="text-app-600"
830
+ >
831
+ · {{ carryShare(row.original.carryCostTokens) }}%
832
+ </span>
833
+ </span>
834
+ </template>
835
+ </UTable>
805
836
  </div>
806
837
  </section>
807
838
 
@@ -863,7 +894,9 @@ function exportJson() {
863
894
  class="overflow-hidden rounded-xl border border-default bg-default/40"
864
895
  :class="!c.ok ? 'border-app-error-900/60' : ''"
865
896
  >
866
- <button
897
+ <UButton
898
+ color="neutral"
899
+ variant="ghost"
867
900
  class="flex w-full items-center gap-3 px-4 py-2.5 text-start transition hover:bg-default/70"
868
901
  @click="toggle(c)"
869
902
  >
@@ -935,7 +968,7 @@ function exportJson() {
935
968
  }}</span>
936
969
  <span class="hidden text-app-600 md:inline">{{ clock(c.createdAt) }}</span>
937
970
  </div>
938
- </button>
971
+ </UButton>
939
972
 
940
973
  <div v-if="expanded[c.id]" class="border-t border-default px-4 py-3 space-y-3">
941
974
  <p v-if="c.errorMessage" class="text-xs text-app-error-400">
@@ -1088,7 +1121,9 @@ function exportJson() {
1088
1121
  :key="s.id"
1089
1122
  class="overflow-hidden rounded-xl border border-default bg-default/40"
1090
1123
  >
1091
- <button
1124
+ <UButton
1125
+ color="neutral"
1126
+ variant="ghost"
1092
1127
  class="flex w-full items-center gap-3 px-4 py-2.5 text-start transition hover:bg-default/70"
1093
1128
  @click="toggleCtx(s)"
1094
1129
  >
@@ -1115,7 +1150,7 @@ function exportJson() {
1115
1150
  }}</span>
1116
1151
  <span class="hidden text-app-600 md:inline">{{ clock(s.createdAt) }}</span>
1117
1152
  </div>
1118
- </button>
1153
+ </UButton>
1119
1154
 
1120
1155
  <div v-if="expandedCtx[s.id]" class="border-t border-default px-4 py-3 space-y-3">
1121
1156
  <div>