@cat-factory/app 0.195.2 → 0.196.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 (39) hide show
  1. package/README.md +10 -3
  2. package/app/components/brainstorm/BrainstormWindow.vue +11 -4
  3. package/app/components/clarity/ClarityReviewWindow.vue +11 -4
  4. package/app/components/initiative/InitiativePlanReview.vue +44 -37
  5. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
  6. package/app/components/layout/SideBar.vue +13 -2
  7. package/app/components/layout/UiModeSwitcher.vue +66 -39
  8. package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
  9. package/app/components/panels/ResultWindowShell.logic.ts +31 -0
  10. package/app/components/panels/ResultWindowShell.vue +37 -8
  11. package/app/components/panels/StepMetadataCard.vue +16 -0
  12. package/app/components/panels/StepRunMeta.vue +18 -0
  13. package/app/components/pipeline/PipelineBuilder.vue +46 -0
  14. package/app/components/prReview/PrReviewWindow.vue +15 -7
  15. package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
  16. package/app/components/spec/ServiceSpecWindow.vue +7 -4
  17. package/app/components/testing/TestReportWindow.vue +11 -6
  18. package/app/composables/api/errors.ts +7 -0
  19. package/app/composables/usePipelineErrorToast.spec.ts +119 -5
  20. package/app/composables/usePipelineErrorToast.ts +140 -10
  21. package/app/composables/useStepPromptVariant.spec.ts +75 -0
  22. package/app/composables/useStepPromptVariant.ts +50 -0
  23. package/app/modular/nav-contributions.ts +3 -3
  24. package/app/stores/agents.spec.ts +30 -0
  25. package/app/stores/agents.ts +33 -1
  26. package/app/stores/pipelines/draftStepConfig.ts +24 -1
  27. package/app/stores/workspace/hydrate.ts +3 -0
  28. package/app/types/domain.ts +1 -0
  29. package/i18n/locales/de.json +25 -2
  30. package/i18n/locales/en.json +37 -2
  31. package/i18n/locales/es.json +25 -2
  32. package/i18n/locales/fr.json +25 -2
  33. package/i18n/locales/he.json +25 -2
  34. package/i18n/locales/it.json +25 -2
  35. package/i18n/locales/ja.json +25 -2
  36. package/i18n/locales/pl.json +25 -2
  37. package/i18n/locales/tr.json +25 -2
  38. package/i18n/locales/uk.json +25 -2
  39. package/package.json +2 -2
@@ -0,0 +1,31 @@
1
+ // The result-window width vocabulary, extracted from `ResultWindowShell.vue` so it can be
2
+ // asserted (see `ResultWindowShell.logic.spec.ts`, which pins every window's bucket against a
3
+ // table naming its reason — the shape `nav-contributions.spec.ts` uses for the advanced-nav set).
4
+ //
5
+ // WHICH bucket a window takes, and the reading-measure obligation `full` carries, are documented
6
+ // on the shell's `width` prop — that is what a window author reads. This module owns only the
7
+ // vocabulary and its class mapping.
8
+
9
+ /** Card width buckets — see `ResultWindowShell.vue`'s `width` prop for what picks `full`. */
10
+ export type ResultWindowWidth = '3xl' | '4xl' | '5xl' | 'full'
11
+
12
+ /**
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.
18
+ */
19
+ export const RESULT_WINDOW_WIDTH_CLASS: Record<ResultWindowWidth, string> = {
20
+ '3xl': 'max-w-3xl',
21
+ '4xl': 'max-w-4xl',
22
+ '5xl': 'max-w-5xl',
23
+ full: 'max-w-none',
24
+ }
25
+
26
+ /**
27
+ * The reading measure a `full` window puts on a run of continuous prose — the step reader's own
28
+ * (`AgentStepDetail`, `mx-auto max-w-3xl` over the same 13px `.reader-prose`), so the surfaces
29
+ * cannot drift into two opinions about how wide prose should be.
30
+ */
31
+ export const PROSE_MEASURE_CLASS = 'max-w-3xl'
@@ -26,6 +26,10 @@ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
26
26
  import StepEffortReport from '~/components/panels/StepEffortReport.vue'
27
27
  import StepValidationReport from '~/components/panels/StepValidationReport.vue'
28
28
  import { effortBand, effortHint } from '~/utils/effort'
29
+ import {
30
+ RESULT_WINDOW_WIDTH_CLASS,
31
+ type ResultWindowWidth,
32
+ } from '~/components/panels/ResultWindowShell.logic'
29
33
 
30
34
  /** A pipeline step reference — passed by step-result windows to surface the shared
31
35
  * "restart from here" control. `StepRestartControl` self-hides for an off-path open
@@ -42,8 +46,38 @@ const props = withDefaults(
42
46
  /** Header title (the accessible dialog name) + optional secondary line. */
43
47
  title: string
44
48
  subtitle?: string
45
- /** Card width bucket + backdrop layout (the two pre-slice-5 chrome variants). */
46
- width?: '3xl' | '4xl' | '5xl'
49
+ /**
50
+ * Card width bucket + backdrop layout (the two pre-slice-5 chrome variants).
51
+ *
52
+ * `full` is the REVIEW/READING bucket: the panel takes the whole viewport minus the
53
+ * shell's own gutter, the shape the full-bleed step reader (`AgentStepDetail`) already
54
+ * has. It is for a window whose body lays out in COLUMNS — rails plus a fluid main
55
+ * column — where the width buys visible layout: the outline and the review rail stop
56
+ * competing with the document, a findings list stops wrapping every card, a diff or a
57
+ * results table stops scrolling sideways. A window that is one column of prose or a
58
+ * short verdict keeps a bucket: stretching two paragraphs across an ultrawide reads
59
+ * worse, not better.
60
+ *
61
+ * The obligation that comes with it: CONTINUOUS PROSE inside a `full` window carries its
62
+ * own reading measure (`PROSE_MEASURE_CLASS`, the step reader's own, over the same 13px
63
+ * `.reader-prose`), or the width lands as 200-character lines.
64
+ *
65
+ * The unit that obligation attaches to is the PARAGRAPH, not the section — which is the
66
+ * distinction to get right, because "a findings list reads better at the full span" is
67
+ * true of the LIST and false of the prose inside each row. A list's rows, badge rows,
68
+ * control rows, tables, Gherkin blocks, log tails and inputs all take the span; a
69
+ * finding's detail, a recorded answer, an investigator's justification and a summary
70
+ * paragraph are prose wherever they sit, and take the measure. Sizing by section is how a
71
+ * card whose answer control is STACKED under its question — every finding card here —
72
+ * ends up arguing that its question is "beside" something and keeping 200-character lines.
73
+ *
74
+ * What `full` costs: click-outside effectively goes, since the backdrop is then only the
75
+ * shell's own gutter. That is the same trade the full-bleed reader already makes (it has
76
+ * no backdrop close at all), and Escape plus the header's close button — the two paths a
77
+ * keyboard and a pointer user actually reach for — are untouched. A window that wants
78
+ * click-outside to stay hittable is a window that should have kept a bucket.
79
+ */
80
+ width?: ResultWindowWidth
47
81
  variant?: 'stretch' | 'centered'
48
82
  /** Provide on step-result windows to show the shared restart control; omit on gates
49
83
  * and block-keyed windows (no restart mid-gate / pre-run). */
@@ -119,18 +153,13 @@ const chipClass = computed(() =>
119
153
  effortReport.value ? CHIP_CLASS[effortBand(effortReport.value.difficulty)] : '',
120
154
  )
121
155
 
122
- const WIDTH: Record<'3xl' | '4xl' | '5xl', string> = {
123
- '3xl': 'max-w-3xl',
124
- '4xl': 'max-w-4xl',
125
- '5xl': 'max-w-5xl',
126
- }
127
156
  const backdropClass = computed(() => [
128
157
  'fixed inset-0 z-50 flex max-h-[100dvh] justify-center bg-slate-950/70 backdrop-blur-sm',
129
158
  props.variant === 'centered' ? 'items-center p-4' : 'items-stretch',
130
159
  ])
131
160
  const panelClass = computed(() => [
132
161
  'flex w-full flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl',
133
- WIDTH[props.width],
162
+ RESULT_WINDOW_WIDTH_CLASS[props.width],
134
163
  props.variant === 'centered' ? 'max-h-[90dvh]' : 'm-4',
135
164
  ])
136
165
  </script>
@@ -48,6 +48,13 @@ const stateMeta = computed(() => {
48
48
 
49
49
  const modelLabel = computed(() => (props.step.model ? models.labelForRef(props.step.model) : null))
50
50
 
51
+ /**
52
+ * The deployment-registered VARIANT this step ran under — an alternate prompt for its agent kind.
53
+ * Reported beside the model because it is the other half of "what actually ran"; null on every
54
+ * step that ran the shipped prompt, so the field is simply absent on the stock product.
55
+ */
56
+ const promptVariant = useStepPromptVariant(() => props.step)
57
+
51
58
  const ITEM_ICON: Record<string, string> = {
52
59
  completed: 'i-lucide-check-circle-2',
53
60
  in_progress: 'i-lucide-loader-circle',
@@ -140,6 +147,15 @@ async function copyRunId() {
140
147
  {{ modelLabel ?? t('panels.stepMeta.notRecorded') }}
141
148
  </dd>
142
149
  </div>
150
+ <div v-if="promptVariant">
151
+ <dt class="text-[11px] uppercase tracking-wide text-slate-500">
152
+ {{ t('panels.stepMeta.promptVariant') }}
153
+ </dt>
154
+ <dd class="mt-0.5 truncate text-slate-300">{{ promptVariant.label }}</dd>
155
+ <dd v-if="promptVariant.note" class="mt-0.5 text-[11px] text-amber-400/80">
156
+ {{ promptVariant.note }}
157
+ </dd>
158
+ </div>
143
159
  <!-- The run id this step belongs to, surfaced for debugging (copyable). -->
144
160
  <div class="col-span-2 sm:col-span-3">
145
161
  <dt class="text-[11px] uppercase tracking-wide text-slate-500">
@@ -27,6 +27,14 @@ const props = defineProps<{
27
27
  const models = useModelsStore()
28
28
  const { t, d } = useI18n()
29
29
 
30
+ /**
31
+ * The deployment-registered VARIANT this step ran under — an alternate prompt for its agent kind.
32
+ * Reported beside the model because it is the other half of "what actually ran": two steps of the
33
+ * same kind on the same model can be told to be different things, and nothing else on this panel
34
+ * would say so. Null on every step that ran the shipped prompt.
35
+ */
36
+ const promptVariant = useStepPromptVariant(() => props.step)
37
+
30
38
  const { isRunning, durationLabel, activityAgoLabel } = useStepTimer({
31
39
  step: () => props.step,
32
40
  runFailed: () => props.runFailed ?? false,
@@ -110,6 +118,16 @@ async function copyRunId() {
110
118
  </p>
111
119
  </div>
112
120
 
121
+ <div v-if="promptVariant">
122
+ <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
123
+ {{ t('panels.stepMeta.promptVariant') }}
124
+ </h4>
125
+ <p class="break-all text-[12px] text-slate-300">{{ promptVariant.label }}</p>
126
+ <p v-if="promptVariant.note" class="mt-0.5 text-[11px] text-amber-400/80">
127
+ {{ promptVariant.note }}
128
+ </p>
129
+ </div>
130
+
113
131
  <div v-if="runId">
114
132
  <h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
115
133
  {{ t('panels.stepMeta.run') }}
@@ -151,6 +151,29 @@ function showOutputBudget(index: number): boolean {
151
151
  function inheritedOutputBudget(kind: AgentKind): number | undefined {
152
152
  return agentSettings.maxOutputTokensFor(kind)
153
153
  }
154
+ /**
155
+ * Whether to offer the agent-kind VARIANT picker on this step: only when the deployment
156
+ * registered variants for its kind, and then only in advanced mode WHILE the step is still on the
157
+ * shipped prompt. Picking a variant is an OVERRIDE of what the kind ships, so `showOverrideField`
158
+ * keeps it visible the moment one is set — a step varied by a teammate (or by the API) must never
159
+ * become invisible to a basic-mode user who would then have no way to see, let alone undo, it.
160
+ */
161
+ function showVariantPicker(index: number, kind: AgentKind): boolean {
162
+ if (!agents.variantsForKind(kind).length) return false
163
+ return showOverrideField(uiMode.isAdvanced, pipelines.draftAgentVariantId(index) ?? null)
164
+ }
165
+
166
+ /**
167
+ * The variants registered for a step's kind as USelect items, with an explicit "shipped prompt"
168
+ * entry so clearing the pick is a choice in the same list rather than a separate affordance.
169
+ */
170
+ function variantSelectItems(kind: AgentKind) {
171
+ return [
172
+ { label: t('pipeline.builder.variantShipped'), value: '' },
173
+ ...agents.variantsForKind(kind).map((variant) => ({ label: variant.label, value: variant.id })),
174
+ ]
175
+ }
176
+
154
177
  const releaseHealth = useReleaseHealthStore()
155
178
  const skills = useSkillsStore()
156
179
 
@@ -746,6 +769,29 @@ async function clone(p: Pipeline) {
746
769
  </p>
747
770
  </div>
748
771
 
772
+ <!-- Agent-kind VARIANT picker: a deployment-registered alternate prompt for this
773
+ step's kind (`stepOptions.agentVariantId`). The step still runs the kind — only
774
+ the prompt changes — so this is an override of the shipped text, shown only where
775
+ the deployment registered one. -->
776
+ <div
777
+ v-if="showVariantPicker(unit.index, unit.kind)"
778
+ class="ms-6 flex items-center gap-2"
779
+ >
780
+ <span class="text-[10px] text-slate-500">
781
+ {{ t('pipeline.builder.variantLabel') }}
782
+ </span>
783
+ <USelect
784
+ class="w-56"
785
+ :model-value="pipelines.draftAgentVariantId(unit.index) ?? ''"
786
+ :items="variantSelectItems(unit.kind)"
787
+ value-key="value"
788
+ size="xs"
789
+ @update:model-value="
790
+ pipelines.setDraftAgentVariantId(unit.index, $event || undefined)
791
+ "
792
+ />
793
+ </div>
794
+
749
795
  <!-- This step's own output-token ceiling. An OVERRIDE of the workspace's per-kind
750
796
  setting (itself an override of the deployment routing default), so it is
751
797
  advanced-only until a value is pinned; empty inherits. -->
@@ -266,7 +266,7 @@ async function onDismiss(id: string): Promise<void> {
266
266
  icon-class="bg-indigo-500/15 text-indigo-300"
267
267
  :title="block ? t('prReview.titleWithBlock', { title: block.title }) : t('prReview.title')"
268
268
  :subtitle="t('prReview.subtitle')"
269
- width="3xl"
269
+ width="full"
270
270
  testid="pr-review-window"
271
271
  @close="close"
272
272
  >
@@ -525,10 +525,11 @@ async function onDismiss(id: string): Promise<void> {
525
525
  </p>
526
526
  </div>
527
527
 
528
- <!-- The reviewer's overall assessment. -->
528
+ <!-- The reviewer's overall assessment. Prose, so it takes the reading measure (see the
529
+ shell's `width` prop) — this window is `full`-width. -->
529
530
  <p
530
531
  v-if="state?.summary"
531
- class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
532
+ class="mb-3 max-w-3xl rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
532
533
  >
533
534
  <span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
534
535
  {{ state.summary }}
@@ -579,7 +580,7 @@ async function onDismiss(id: string): Promise<void> {
579
580
  <h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
580
581
  {{ g.title }}
581
582
  </h3>
582
- <p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
583
+ <p v-if="g.rationale" class="mb-1.5 max-w-3xl text-[11px] text-slate-500">
583
584
  {{ g.rationale }}
584
585
  </p>
585
586
  <article
@@ -672,15 +673,22 @@ async function onDismiss(id: string): Promise<void> {
672
673
  · {{ t('prReview.line', { line: f.line }) }}</template
673
674
  >
674
675
  </p>
676
+ <!-- The reviewer's prose — what the finding is, what to do about it, and the
677
+ investigator's verdict below. Each takes the reading measure even though
678
+ the card around it takes the span (see the shell's `width` prop: the unit
679
+ is the paragraph, not the section). This window went from the NARROWEST
680
+ bucket to `full`, so these are the three paragraphs the width would
681
+ otherwise have stretched furthest; the path/line row, the badges and the
682
+ per-finding actions are what it is actually for. -->
675
683
  <p
676
- class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300"
684
+ class="mt-1 max-w-3xl whitespace-pre-wrap text-[12px] text-slate-300"
677
685
  :class="isRetracted(f) ? 'line-through' : ''"
678
686
  >
679
687
  {{ f.detail }}
680
688
  </p>
681
689
  <p
682
690
  v-if="f.suggestedFix"
683
- class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
691
+ class="mt-1 max-w-3xl whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
684
692
  >
685
693
  <span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
686
694
  {{ f.suggestedFix }}
@@ -691,7 +699,7 @@ async function onDismiss(id: string): Promise<void> {
691
699
  <p
692
700
  v-if="f.challenge?.justification"
693
701
  data-testid="pr-review-finding-justification"
694
- class="mt-1.5 whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
702
+ class="mt-1.5 max-w-3xl whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
695
703
  :class="
696
704
  isRetracted(f)
697
705
  ? 'bg-rose-500/10 text-rose-200'
@@ -653,7 +653,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
653
653
  :subtitle="block?.title"
654
654
  :step-ref="{ instanceId, stepIndex }"
655
655
  variant="centered"
656
- width="5xl"
656
+ width="full"
657
657
  @close="close"
658
658
  >
659
659
  <template v-if="review" #header-extras>
@@ -796,7 +796,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
796
796
  {{ STATUS_LABELS[item.status] }}
797
797
  </UBadge>
798
798
  </div>
799
- <p class="mt-1 whitespace-pre-line text-sm text-slate-400">
799
+ <!-- The reviewer's question is prose, so it takes the measure even though the
800
+ card around it takes the span (see the shell's `width` prop: the unit is
801
+ the paragraph, not the section). The badge row above and the mode buttons
802
+ and textarea below are what the full width is actually for. -->
803
+ <p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-400">
800
804
  {{ item.detail }}
801
805
  </p>
802
806
 
@@ -804,7 +808,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
804
808
  ones the answer lives in the textarea below, seeded from the reply) -->
805
809
  <div
806
810
  v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
807
- class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
811
+ class="mt-2 max-w-3xl rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
808
812
  >
809
813
  <span class="text-[10px] uppercase tracking-wide text-slate-500">
810
814
  {{ t('requirements.answerLabel') }}
@@ -925,7 +929,9 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
925
929
  >
926
930
  {{ GROUNDING_LABELS[rec.groundedIn] }}
927
931
  </UBadge>
928
- <p class="mt-1 whitespace-pre-line text-sm text-slate-300">
932
+ <!-- The Writer's suggested answer — agent prose, so it takes the
933
+ measure like the finding's own question above it. -->
934
+ <p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-300">
929
935
  {{ rec.recommendedText }}
930
936
  </p>
931
937
  <div class="mt-2 flex flex-wrap items-center gap-2">
@@ -1037,7 +1043,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
1037
1043
  }}
1038
1044
  </span>
1039
1045
  </button>
1040
- <div v-show="!docCollapsed">
1046
+ <!-- The same reading measure the findings' own prose takes above (see the shell's
1047
+ `width` prop): the window is `full`-width now, and this is continuous prose that
1048
+ would otherwise run to 200-character lines. Left-aligned rather than centred, so
1049
+ it starts where every finding above it starts. -->
1050
+ <div v-show="!docCollapsed" class="max-w-3xl">
1041
1051
  <div v-for="s in outline.sections" :key="s.id" class="mb-2">
1042
1052
  <button
1043
1053
  v-if="s.title"
@@ -185,7 +185,7 @@ function kindLabel(item: RequirementItem): string {
185
185
  :title="t('spec.title')"
186
186
  :subtitle="block ? spec?.service || block.title : undefined"
187
187
  variant="centered"
188
- width="5xl"
188
+ width="full"
189
189
  @close="close"
190
190
  >
191
191
  <!-- view toggle: Gherkin only when the spec (and its feature files) are on main -->
@@ -318,7 +318,10 @@ function kindLabel(item: RequirementItem): string {
318
318
  <!-- service overview -->
319
319
  <template v-if="selected === null">
320
320
  <h2 class="text-lg font-semibold text-white">{{ spec?.service }}</h2>
321
- <p v-if="spec?.summary" class="mt-2 whitespace-pre-line text-sm text-slate-300">
321
+ <!-- The service's own prose, so it takes the reading measure the shell's `full` width
322
+ obliges (see the `width` prop). The requirement rows and Gherkin blocks below keep
323
+ the full span — they are structure, not paragraphs. -->
324
+ <p v-if="spec?.summary" class="mt-2 max-w-3xl whitespace-pre-line text-sm text-slate-300">
322
325
  {{ spec.summary }}
323
326
  </p>
324
327
  <p v-else class="mt-2 text-sm text-slate-500">{{ t('spec.noSummary') }}</p>
@@ -352,7 +355,7 @@ function kindLabel(item: RequirementItem): string {
352
355
  {{ selectedModule?.name }}
353
356
  </div>
354
357
  <h2 class="text-lg font-semibold text-white">{{ selectedGroup.name }}</h2>
355
- <p v-if="selectedGroup.summary" class="mt-1 text-sm text-slate-400">
358
+ <p v-if="selectedGroup.summary" class="mt-1 max-w-3xl text-sm text-slate-400">
356
359
  {{ selectedGroup.summary }}
357
360
  </p>
358
361
 
@@ -500,7 +503,7 @@ function kindLabel(item: RequirementItem): string {
500
503
  <UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5" />
501
504
  {{ t('spec.domainRules') }}
502
505
  </div>
503
- <ul class="space-y-1.5">
506
+ <ul class="max-w-3xl space-y-1.5">
504
507
  <li
505
508
  v-for="rule in selectedGroup.rules ?? []"
506
509
  :key="rule.id"
@@ -313,7 +313,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
313
313
  :title="headerTitle"
314
314
  :subtitle="t('testing.subtitle')"
315
315
  :step-ref="{ instanceId, stepIndex }"
316
- width="5xl"
316
+ width="full"
317
317
  testid="tester-report-window"
318
318
  @close="close"
319
319
  >
@@ -489,7 +489,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
489
489
  :icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
490
490
  :icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
491
491
  />
492
- <p v-if="a.summary" class="mt-1 text-[12px] leading-snug text-slate-400">
492
+ <p v-if="a.summary" class="mt-1 max-w-3xl text-[12px] leading-snug text-slate-400">
493
493
  {{ a.summary }}
494
494
  </p>
495
495
  <div v-if="a.concerns && a.concerns.length" class="mt-1.5">
@@ -607,8 +607,13 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
607
607
  </div>
608
608
 
609
609
  <template v-else>
610
- <!-- Summary -->
611
- <p v-if="report.summary" class="mb-4 text-[13px] leading-relaxed text-slate-300">
610
+ <!-- Summary — the tester's own prose, so it takes the reading measure the shell's `full`
611
+ width obliges (see the `width` prop). The scenario rows and log tails below keep the
612
+ full span. -->
613
+ <p
614
+ v-if="report.summary"
615
+ class="mb-4 max-w-3xl text-[13px] leading-relaxed text-slate-300"
616
+ >
612
617
  {{ report.summary }}
613
618
  </p>
614
619
 
@@ -675,7 +680,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
675
680
  />
676
681
  <div class="min-w-0">
677
682
  <span class="text-[13px] text-slate-200">{{ o.name }}</span>
678
- <p v-if="o.detail" class="text-[12px] leading-snug text-slate-400">
683
+ <p v-if="o.detail" class="max-w-3xl text-[12px] leading-snug text-slate-400">
679
684
  {{ o.detail }}
680
685
  </p>
681
686
  </div>
@@ -705,7 +710,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
705
710
  {{ SEVERITY_LABELS[c.severity] }}
706
711
  </span>
707
712
  </div>
708
- <p v-if="c.detail" class="text-[12px] leading-snug text-slate-400">
713
+ <p v-if="c.detail" class="max-w-3xl text-[12px] leading-snug text-slate-400">
709
714
  {{ c.detail }}
710
715
  </p>
711
716
  </div>
@@ -29,10 +29,17 @@ export class ApiError extends Error {
29
29
 
30
30
  /** The error envelope every controller emits (`handleError` / contract request-validator). */
31
31
  export interface ApiErrorEnvelope {
32
+ /** The status class — an `ApiErrorCode` from `@cat-factory/contracts`, when recognised. */
32
33
  code?: string
33
34
  message?: string
34
35
  details?: unknown
35
36
  issues?: { path?: string; message: string }[]
37
+ /**
38
+ * The request's correlation id (`mountRequestLogging` mints or adopts `X-Request-Id` and
39
+ * `handleError` puts it on EVERY envelope). It is the join between what the user saw and the
40
+ * one server log line that explains it, so any surface showing failure detail should quote it.
41
+ */
42
+ requestId?: string
36
43
  }
37
44
 
38
45
  /** Read the `{ error: {...} }` envelope out of a parsed response body, else undefined. */
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest'
2
- import { usePipelineErrorToast, parseConflict } from '~/composables/usePipelineErrorToast'
2
+ import {
3
+ usePipelineErrorToast,
4
+ parseConflict,
5
+ describeGenericFailure,
6
+ } from '~/composables/usePipelineErrorToast'
3
7
  import { ApiError } from '~/composables/api/errors'
4
8
  import en from '../../i18n/locales/en.json'
5
9
 
@@ -9,6 +13,9 @@ import en from '../../i18n/locales/en.json'
9
13
  * title AND the description (G1) — and only ever shows raw backend prose as a last-resort
10
14
  * description (an unmapped reason). These specs assert the KEYS and params a code path
11
15
  * resolves (never the English text), so they stay locale-agnostic.
16
+ *
17
+ * The same holds for the NON-conflict funnel (G2): the description is keyed off the envelope's
18
+ * status class and the raw prose is only reachable behind the "Show details" disclosure.
12
19
  */
13
20
 
14
21
  /** Dot-path lookup into the real `en.json`, so `te` mirrors which keys actually ship. */
@@ -21,11 +28,15 @@ function hasKey(path: string): boolean {
21
28
  }
22
29
 
23
30
  let add: ReturnType<typeof vi.fn>
31
+ let update: ReturnType<typeof vi.fn>
24
32
  let t: ReturnType<typeof vi.fn>
25
33
  let ui: Record<string, ReturnType<typeof vi.fn>>
26
34
 
27
35
  beforeEach(() => {
28
- add = vi.fn()
36
+ // `add` returns the created toast (Nuxt UI hands back the generated id synchronously), which
37
+ // the detail disclosure needs in order to `update` the SAME toast in place.
38
+ add = vi.fn(() => ({ id: 'toast-1' }))
39
+ update = vi.fn()
29
40
  // `t` echoes the key so the toast's title/description IS the resolved key — assert on it.
30
41
  t = vi.fn((key: string) => key)
31
42
  // The ui-store deep-links a jump action may navigate to (each echoed as a spy).
@@ -36,7 +47,7 @@ beforeEach(() => {
36
47
  openModelConfig: vi.fn(),
37
48
  openProviderConnection: vi.fn(),
38
49
  }
39
- vi.stubGlobal('useToast', () => ({ add }))
50
+ vi.stubGlobal('useToast', () => ({ add, update }))
40
51
  vi.stubGlobal('useUiStore', () => ui)
41
52
  vi.stubGlobal('useI18n', () => ({ t, te: (key: string) => hasKey(key) }))
42
53
  })
@@ -128,10 +139,113 @@ describe('usePipelineErrorToast', () => {
128
139
  expect(ui.openAiProviderSetup).toHaveBeenCalledOnce()
129
140
  })
130
141
 
131
- it('uses the fallback title key + raw message for a non-conflict error', () => {
142
+ it('uses the fallback title key + a TRANSLATED description for a non-conflict error', () => {
143
+ // G2: the raw JS/backend prose is no longer the description — a bare throw with no HTTP
144
+ // answer at all is presented as the network case.
132
145
  usePipelineErrorToast().present(new Error('boom'), 'errors.action.startFailed')
133
146
  const arg = add.mock.calls[0]![0]
134
147
  expect(arg.title).toBe('errors.action.startFailed')
135
- expect(arg.description).toBe('boom')
148
+ expect(arg.description).toBe('errors.generic.description.network')
149
+ expect(arg.description).not.toBe('boom')
150
+ })
151
+
152
+ it('keys the description off the envelope status class, not the backend prose', () => {
153
+ usePipelineErrorToast().present(
154
+ new ApiError(503, { error: { code: 'unavailable', message: 'Task sources not configured' } }),
155
+ )
156
+ expect(add.mock.calls[0]![0].description).toBe('errors.generic.description.unavailable')
157
+ })
158
+
159
+ it('reveals the raw detail in place when "Show details" is clicked, and makes it sticky', () => {
160
+ usePipelineErrorToast().present(
161
+ new ApiError(503, { error: { code: 'unavailable', message: 'Task sources not configured' } }),
162
+ )
163
+ const arg = add.mock.calls[0]![0]
164
+ // Auto-dismissing until the user asks for detail: no `duration` override up front.
165
+ expect(arg.duration).toBeUndefined()
166
+ expect(arg.actions[0].label).toBe('errors.generic.showDetail')
167
+ arg.actions[0].onClick()
168
+ // Same toast, not a second one; sticky, and the button is dropped so it can't be re-clicked.
169
+ expect(add).toHaveBeenCalledTimes(1)
170
+ expect(update).toHaveBeenCalledWith('toast-1', {
171
+ description: 'Task sources not configured',
172
+ duration: 0,
173
+ actions: [],
174
+ })
175
+ })
176
+
177
+ it('folds validation issues and the requestId into the revealed detail', () => {
178
+ usePipelineErrorToast().present(
179
+ new ApiError(400, {
180
+ error: {
181
+ code: 'validation',
182
+ message: 'Request failed validation',
183
+ requestId: 'req-42',
184
+ issues: [{ path: 'body.title', message: 'Required' }, { message: 'Unexpected field' }],
185
+ },
186
+ }),
187
+ )
188
+ const arg = add.mock.calls[0]![0]
189
+ expect(arg.description).toBe('errors.generic.description.validation')
190
+ arg.actions[0].onClick()
191
+ expect(t).toHaveBeenCalledWith('errors.generic.requestId', { id: 'req-42' })
192
+ // The issues carry the real information on a 422/400 (the message is the fixed
193
+ // `Request failed validation`), so they must reach the disclosure.
194
+ expect(update.mock.calls[0]![1].description).toBe(
195
+ 'Request failed validation · body.title: Required, Unexpected field · errors.generic.requestId',
196
+ )
197
+ })
198
+
199
+ it('offers no disclosure when there is no detail to reveal', () => {
200
+ usePipelineErrorToast().present(new ApiError(503, { error: { code: 'unavailable' } }))
201
+ const arg = add.mock.calls[0]![0]
202
+ // `ApiError` synthesises `Request failed (HTTP 503)` when the envelope carries no message,
203
+ // so a truly detail-less case is a non-Error throw.
204
+ expect(arg.description).toBe('errors.generic.description.unavailable')
205
+ expect(arg.actions[0].label).toBe('errors.generic.showDetail')
206
+ usePipelineErrorToast().present(null)
207
+ expect(add.mock.calls[1]![0].actions).toBeUndefined()
208
+ })
209
+ })
210
+
211
+ describe('describeGenericFailure', () => {
212
+ it('maps each known status class to its own description key', () => {
213
+ for (const code of [
214
+ 'not_found',
215
+ 'validation',
216
+ 'credential_required',
217
+ 'forbidden',
218
+ 'unavailable',
219
+ 'unauthorized',
220
+ 'rate_limited',
221
+ 'internal',
222
+ ]) {
223
+ const failure = describeGenericFailure(new ApiError(500, { error: { code } }))
224
+ expect(failure.descriptionKey).toBe(`errors.generic.description.${code}`)
225
+ expect(hasKey(failure.descriptionKey)).toBe(true)
226
+ }
227
+ })
228
+
229
+ it('separates "nothing answered" from "something answered unrecognisably"', () => {
230
+ // No envelope AND no status: offline / DNS / dropped connection — the remedy is the user's.
231
+ expect(describeGenericFailure(new Error('Failed to fetch')).descriptionKey).toBe(
232
+ 'errors.generic.description.network',
233
+ )
234
+ // A status but not our envelope (an edge 502 page) — the remedy is the server's.
235
+ expect(
236
+ describeGenericFailure(new ApiError(502, '<html>bad gateway</html>')).descriptionKey,
237
+ ).toBe('errors.generic.description.unexpected')
238
+ // Our envelope, but a code this build does not know.
239
+ expect(
240
+ describeGenericFailure(new ApiError(418, { error: { code: 'teapot' } })).descriptionKey,
241
+ ).toBe('errors.generic.description.unexpected')
242
+ })
243
+
244
+ it('never presents a conflict (parseConflict owns those) but still classifies safely', () => {
245
+ // `conflict` is deliberately absent from the map, so it reads as an unrecognised code rather
246
+ // than throwing — the conflict path intercepts it long before this function is reached.
247
+ expect(
248
+ describeGenericFailure(new ApiError(409, { error: { code: 'conflict' } })).descriptionKey,
249
+ ).toBe('errors.generic.description.unexpected')
136
250
  })
137
251
  })