@cat-factory/app 0.190.0 → 0.190.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.
package/README.md CHANGED
@@ -54,6 +54,16 @@ over the WebSocket. How that sync works is written up in
54
54
  | `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
55
55
  | `utils/` | Small pure helpers. |
56
56
 
57
+ ### Always import a layer component explicitly
58
+
59
+ **Import a component under `components/` by path before using it in a template.** Do not lean on Nuxt's auto-registration. This layer sets no `components` config, so the default `pathPrefix: true` applies and a component is registered under its path-prefixed name: `components/panels/StepEffortReport.vue` becomes `PanelsStepEffortReport`, and a bare `<StepEffortReport>` matches nothing.
60
+
61
+ Some bare tags do work, which is exactly what makes this worth writing down. Nuxt drops a directory segment the filename already repeats, so `pipeline/PipelinePicker.vue` registers as `PipelinePicker` and resolves bare, while `pipeline/AgentKindIcon.vue` in the same folder registers as `PipelineAgentKindIcon` and does not. Whether a tag resolves therefore depends on a coincidence between a folder name and a filename, and renaming either end breaks the tag with no error. An explicit import does not care.
62
+
63
+ The failure is silent, which is why this is a rule rather than a preference. An unresolved tag warns in dev and then renders nothing, so a built SPA has a hole where the component should be. Nothing catches it: not typecheck, not the unit tests, not the e2e suite, and not the user, who reads it as a backend returning no data. Seven components had shipped this way.
64
+
65
+ `scripts/check-component-imports.mjs` enforces it (CI's `repo-guards` job). If a panel section is missing and the data looks right, check the import first.
66
+
57
67
  ## Interface modes (basic / advanced)
58
68
 
59
69
  The SPA renders at one of two **interface tiers**. `basic` (the default) is the everyday
@@ -17,6 +17,7 @@
17
17
  import { computed } from 'vue'
18
18
  import { JourneyHost, JourneyOutlet } from '@modular-vue/journeys'
19
19
  import { environmentSetupHandle } from '~/modular/journeys/environmentSetup'
20
+ import EnvSetupStepper from '~/components/environments/EnvSetupStepper.vue'
20
21
 
21
22
  const ui = useUiStore()
22
23
  const { t } = useI18n()
@@ -6,6 +6,8 @@ import { agentKindMeta } from '~/utils/catalog'
6
6
  import StepRestartControl from '~/components/panels/StepRestartControl.vue'
7
7
  import StepMetadataCard from '~/components/panels/StepMetadataCard.vue'
8
8
  import StepTestReport from '~/components/panels/StepTestReport.vue'
9
+ import StepEffortReport from '~/components/panels/StepEffortReport.vue'
10
+ import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
9
11
  import EnvironmentStatusPanel from '~/components/environments/EnvironmentStatusPanel.vue'
10
12
  import FrontendBindingsResolved from '~/components/panels/inspector/FrontendBindingsResolved.vue'
11
13
  import { UI_TESTER_AGENT_KIND } from '@cat-factory/contracts'
@@ -16,6 +16,7 @@ import {
16
16
  segmentPct,
17
17
  trendMagnitude,
18
18
  } from './ReportsPanel.logic'
19
+ import ReportsSpendBreakdown from '~/components/panels/ReportsSpendBreakdown.vue'
19
20
 
20
21
  // Reports: cross-cutting usage analytics for the active account — where the spend and the
21
22
  // work actually go. Spend per model and agent kind, spend + run activity per workspace /
@@ -8,6 +8,7 @@ import { showOverrideField } from '~/utils/uiMode'
8
8
  import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
9
9
  import RiskPolicyPicker from '~/components/riskPolicy/RiskPolicyPicker.vue'
10
10
  import TaskAprioriBranches from '~/components/panels/inspector/TaskAprioriBranches.vue'
11
+ import DocReferenceRepos from '~/components/panels/inspector/DocReferenceRepos.vue'
11
12
 
12
13
  const props = defineProps<{ block: Block }>()
13
14
 
@@ -12,6 +12,7 @@ import { computed } from 'vue'
12
12
  import type { Pipeline } from '~/types/domain'
13
13
  import { agentKindMeta } from '~/utils/catalog'
14
14
  import { pipelineDisplaySteps, pipelineGateCount } from '~/utils/pipeline'
15
+ import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
15
16
 
16
17
  const props = defineProps<{ pipeline: Pipeline }>()
17
18
  const { t } = useI18n()
@@ -24,6 +24,7 @@ import { subtaskIconClass } from '~/utils/pipelineRender'
24
24
  import { activeChunkLabels, chunkReviewPercent, hasNoSlicePlan } from '~/utils/prReviewProgress'
25
25
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
26
26
  import StepRunMeta from '~/components/panels/StepRunMeta.vue'
27
+ import StepFragmentAdherence from '~/components/panels/StepFragmentAdherence.vue'
27
28
 
28
29
  const execution = useExecutionStore()
29
30
  const board = useBoardStore()
@@ -12,6 +12,7 @@ import {
12
12
  RISK_POLICY_CEILING_FIELD,
13
13
  type RiskPolicyAxis,
14
14
  } from '~/utils/riskPolicy'
15
+ import MergeClassRulesEditor from '~/components/settings/MergeClassRulesEditor.vue'
15
16
 
16
17
  const { t } = useI18n()
17
18
 
@@ -0,0 +1,50 @@
1
+ import type { ApiContract } from '@toad-contracts/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { type SendParams, withoutUndefinedQueryParams } from '~/composables/api/client'
4
+
5
+ // An omitted optional query param used to reach the server as `key=`: the contract client
6
+ // serialises with `fast-querystring`, whose `stringify({ blockId: undefined })` is `'blockId='`,
7
+ // and request validation waves the key through because `v.optional(...)` accepts `undefined`. On
8
+ // `listTasksContract`, whose `blockId` carries a `minLength(1)`, that made every unscoped
9
+ // `listTasks()` a guaranteed 400. These lock the strip in.
10
+
11
+ // The helper is contract-generic and only ever reads `queryParams`, so the cases below describe
12
+ // request params structurally rather than picking a real contract per shape.
13
+ type Params = Record<string, unknown>
14
+ const strip = (params: Params): Params =>
15
+ withoutUndefinedQueryParams(params as SendParams<ApiContract>) as Params
16
+
17
+ describe('withoutUndefinedQueryParams', () => {
18
+ it('drops undefined-valued query keys so they never reach the query string', () => {
19
+ const out = strip({ pathPrefix: '/workspaces/ws_1', queryParams: { blockId: undefined } })
20
+ expect(out.queryParams).toEqual({})
21
+ })
22
+
23
+ // The guard against "fix" it with a falsy check: 0, false and '' are all values a caller
24
+ // deliberately sent, and only `undefined` means absent.
25
+ it('keeps defined values, including falsy ones a caller meant to send', () => {
26
+ const out = strip({ queryParams: { blockId: 'blk_1', page: 0, all: false, q: '' } })
27
+ expect(out.queryParams).toEqual({ blockId: 'blk_1', page: 0, all: false, q: '' })
28
+ })
29
+
30
+ it('drops only the undefined keys from a mixed set', () => {
31
+ const out = strip({ queryParams: { window: '7d', workspaceId: undefined } })
32
+ expect(out.queryParams).toEqual({ window: '7d' })
33
+ })
34
+
35
+ it('leaves other request params untouched', () => {
36
+ const params = {
37
+ pathPrefix: '/workspaces/ws_1',
38
+ pathParams: { source: 'jira' },
39
+ body: { a: 1 },
40
+ }
41
+ expect(strip(params)).toEqual(params)
42
+ })
43
+
44
+ it('returns the same object when there is nothing to strip', () => {
45
+ const params = { queryParams: { blockId: 'blk_1' } }
46
+ expect(strip(params)).toBe(params)
47
+ const noQuery = { pathPrefix: '/workspaces/ws_1' }
48
+ expect(strip(noQuery)).toBe(noQuery)
49
+ })
50
+ })
@@ -71,6 +71,31 @@ export function createApiClient(): WretchInstance {
71
71
  ])
72
72
  }
73
73
 
74
+ /**
75
+ * Drop query keys whose value is `undefined`, so an omitted optional param is absent from the
76
+ * query string instead of present-but-empty.
77
+ *
78
+ * `sendByApiContract` serialises with `fast-querystring`, and `stringify({ blockId: undefined })`
79
+ * is `'blockId='`. Request validation does not catch it, because `v.optional(...)` accepts
80
+ * `undefined` and the key only becomes empty on the way out. The server then validates the parsed
81
+ * `''`, so any optional param carrying a `minLength(1)` rejects the whole request with a 400. That
82
+ * is what made an unscoped `listTasks()` uncallable. Params with no length check are luckier but
83
+ * still wrong: the handler reads `''` where it asked for absence.
84
+ *
85
+ * Stripping at this one chokepoint fixes every contract at once, which is the point. Writing
86
+ * `queryParams: { foo }` for an optional `foo` is the obvious thing to write and it should work,
87
+ * rather than each call site remembering to spread the key in conditionally.
88
+ */
89
+ export function withoutUndefinedQueryParams<T extends ApiContract>(
90
+ params: SendParams<T>,
91
+ ): SendParams<T> {
92
+ const query = (params as { queryParams?: Record<string, unknown> }).queryParams
93
+ if (!query) return params
94
+ const present = Object.entries(query).filter(([, value]) => value !== undefined)
95
+ if (present.length === Object.keys(query).length) return params
96
+ return { ...params, queryParams: Object.fromEntries(present) }
97
+ }
98
+
74
99
  /**
75
100
  * Send a contract request and unwrap to the success body (or throw the typed error).
76
101
  * The public signature preserves per-contract inference for callers; inside,
@@ -82,7 +107,7 @@ export async function sendContract<T extends ApiContract>(
82
107
  contract: T,
83
108
  params: SendParams<T>,
84
109
  ): Promise<SuccessBodyOf<T>> {
85
- const outcome = await sendByApiContract(client, contract, params)
110
+ const outcome = await sendByApiContract(client, contract, withoutUndefinedQueryParams(params))
86
111
  if (outcome.error) {
87
112
  const error = outcome.error
88
113
  // A contract-declared non-2xx is reported as a plain `{ statusCode, headers, body }`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.190.0",
3
+ "version": "0.190.1",
4
4
  "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
5
5
  "repository": {
6
6
  "type": "git",