@cat-factory/app 0.296.2 → 0.296.4

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 (50) hide show
  1. package/README.md +4 -3
  2. package/app/components/board/AddTaskModal.vue +63 -3
  3. package/app/components/board/BoardCanvas.vue +4 -92
  4. package/app/components/board/CreateInitiativeModal.vue +2 -2
  5. package/app/components/bugFishing/BugFishingWindow.vue +170 -48
  6. package/app/components/common/DescriptorFields.vue +7 -0
  7. package/app/components/foundational/FoundationalServiceCatalogList.vue +3 -12
  8. package/app/components/foundational/FoundationalServiceSources.vue +2 -11
  9. package/app/components/fragments/FragmentLibraryManager.vue +3 -12
  10. package/app/components/layout/AccountAuditLog.logic.ts +2 -2
  11. package/app/components/panels/ResultWindowShell.logic.spec.ts +7 -22
  12. package/app/components/panels/StepToolServers.logic.ts +2 -2
  13. package/app/components/settings/ConsensusGroupsSection.vue +1 -4
  14. package/app/components/settings/MergeRolePolicyEditor.logic.spec.ts +0 -1
  15. package/app/components/skills/SkillLibraryManager.vue +3 -12
  16. package/app/components/tutorial/TutorialOverlay.logic.ts +2 -2
  17. package/app/composables/api/client.spec.ts +0 -5
  18. package/app/composables/useBusyRows.ts +22 -0
  19. package/app/composables/useFrameResize.ts +0 -5
  20. package/app/composables/useSemanticZoom.ts +1 -1
  21. package/app/modular/panels/inspector.ts +1 -1
  22. package/app/modular/result-views.ts +4 -3
  23. package/app/stores/board/mutations.ts +1 -14
  24. package/app/stores/observability/agentContext.ts +1 -8
  25. package/app/stores/observability/toolCalls.ts +1 -8
  26. package/app/stores/observability/withFlag.ts +9 -0
  27. package/app/types/execution.ts +4 -0
  28. package/app/utils/blockRects.ts +1 -1
  29. package/app/utils/boardWakeGate.ts +1 -1
  30. package/app/utils/catalog.ts +1 -14
  31. package/app/utils/descriptorFields.spec.ts +0 -45
  32. package/app/utils/descriptorFields.ts +1 -13
  33. package/app/utils/dnd.ts +1 -25
  34. package/app/utils/initiative.ts +1 -1
  35. package/app/utils/settlingLoop.ts +1 -1
  36. package/app/utils/taskExpansionRanking.spec.ts +8 -17
  37. package/app/utils/tutorial.ts +1 -1
  38. package/app/utils/uiMode.spec.ts +0 -1
  39. package/app/utils/uiRole.ts +1 -1
  40. package/i18n/locales/de.json +19 -7
  41. package/i18n/locales/en.json +19 -7
  42. package/i18n/locales/es.json +19 -7
  43. package/i18n/locales/fr.json +19 -7
  44. package/i18n/locales/he.json +19 -7
  45. package/i18n/locales/it.json +19 -7
  46. package/i18n/locales/ja.json +19 -7
  47. package/i18n/locales/pl.json +19 -7
  48. package/i18n/locales/tr.json +19 -7
  49. package/i18n/locales/uk.json +19 -7
  50. package/package.json +2 -2
@@ -6,7 +6,8 @@
6
6
  // the merged catalog (built-in ∪ account ∪ workspace) an agent is selected from per
7
7
  // run. The account scope has no resolved/merged catalog and fetches document
8
8
  // fragments through `viaWorkspaceId` (document-source credentials are per-workspace).
9
- import { computed, nextTick, reactive, ref, watch } from 'vue'
9
+ import { computed, nextTick, ref, watch } from 'vue'
10
+ import { useBusyRows } from '~/composables/useBusyRows'
10
11
  import type {
11
12
  DocumentSourceKind,
12
13
  FragmentOwnerKind,
@@ -108,17 +109,7 @@ function tabLabel(which: Tab): string {
108
109
  // Per-row / per-form in-flight tracking. The store's single `library.loading` flag
109
110
  // drove every row's button at once (UX-29) and cross-spun the add/link forms; key
110
111
  // each async action so only the control that triggered it shows a spinner.
111
- const busyRows = reactive(new Set<string>())
112
- const rowBusy = (key: string) => busyRows.has(key)
113
- async function withRow(key: string, fn: () => Promise<void>) {
114
- if (busyRows.has(key)) return
115
- busyRows.add(key)
116
- try {
117
- await fn()
118
- } finally {
119
- busyRows.delete(key)
120
- }
121
- }
112
+ const { rowBusy, withRow } = useBusyRows()
122
113
  const creating = ref(false)
123
114
  const linkingDoc = ref(false)
124
115
  const linkingSource = ref(false)
@@ -47,7 +47,7 @@ export type Translate = (key: string, params?: Record<string, string>) => string
47
47
  * Derived from the contract rather than re-listed, so an action whose fields change cannot leave
48
48
  * this behind: the `Record` it reads is exhaustive over the same picklist `ACTION_KEYS` is.
49
49
  */
50
- export function detailParams(
50
+ function detailParams(
51
51
  action: AuditAction,
52
52
  details: AuditEventWire['details'],
53
53
  none: string,
@@ -61,7 +61,7 @@ export function detailParams(
61
61
  }
62
62
 
63
63
  /** Who the action was performed ON: the resolved name, else the raw id. */
64
- export function targetLabel(event: AuditEventWire): string {
64
+ function targetLabel(event: AuditEventWire): string {
65
65
  return event.targetName ?? event.targetId
66
66
  }
67
67
 
@@ -1,11 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { resolve } from 'node:path'
3
3
  import { describe, expect, it } from 'vitest'
4
- import {
5
- PROSE_MEASURE_CLASS,
6
- RESULT_WINDOW_WIDTH_CLASS,
7
- type ResultWindowWidth,
8
- } from './ResultWindowShell.logic'
4
+ import { PROSE_MEASURE_CLASS, type ResultWindowWidth } from './ResultWindowShell.logic'
9
5
 
10
6
  // The width decision is a per-window LAYOUT judgement that lives on each window's
11
7
  // `<ResultWindowShell width="…">`, which means nothing in the type system can ask a window author
@@ -165,22 +161,11 @@ describe('result-window width buckets', () => {
165
161
  })
166
162
  })
167
163
 
168
- describe('RESULT_WINDOW_WIDTH_CLASS', () => {
169
- it('caps every bucket at its own name and leaves `full` uncapped', () => {
170
- expect(RESULT_WINDOW_WIDTH_CLASS).toEqual({
171
- '3xl': 'max-w-3xl',
172
- '4xl': 'max-w-4xl',
173
- '5xl': 'max-w-5xl',
174
- // Not a bigger number: the panel's `w-full` spans the backdrop and the variant's gutter
175
- // (`m-4` / `p-4`) is the only inset. A cap here would reintroduce the indefensible number
176
- // `full` exists to avoid.
177
- full: 'max-w-none',
178
- })
179
- })
180
-
181
- it('shares one measure with the step reader', () => {
182
- // `AgentStepDetail` reads prose at `mx-auto max-w-3xl`; the windows must not hold a second
183
- // opinion about how wide prose should be.
184
- expect(PROSE_MEASURE_CLASS).toBe('max-w-3xl')
164
+ describe('PROSE_MEASURE_CLASS', () => {
165
+ it('is the measure the step reader already uses', () => {
166
+ // `AgentStepDetail` reads prose at its own measure; the windows must not hold a second
167
+ // opinion about how wide prose should be, so the constant is checked against that source.
168
+ const stepReader = readFileSync(resolve(componentsDir, 'panels/AgentStepDetail.vue'), 'utf8')
169
+ expect(stepReader).toContain(PROSE_MEASURE_CLASS)
185
170
  })
186
171
  })
@@ -81,7 +81,7 @@ export const KNOWN_REASONS = toolServerUnavailableReasonSchema.options
81
81
  * it were a translation key, taking the retired-member path away from exactly the case it exists
82
82
  * for. Narrowing at the boundary keeps the exhaustive `Record` compile-time guard intact.
83
83
  */
84
- export function isKnownReason(reason: string): reason is ToolServerUnavailableReason {
84
+ function isKnownReason(reason: string): reason is ToolServerUnavailableReason {
85
85
  return (KNOWN_REASONS as readonly string[]).includes(reason)
86
86
  }
87
87
 
@@ -205,7 +205,7 @@ export const KNOWN_OBSERVED_STATUSES = toolServerObservedStatusSchema.options
205
205
  * A predicate rather than a truthiness check on the lookup, for the reason {@link isKnownReason}
206
206
  * gives: an `Object.prototype` member name reads back as a truthy non-key.
207
207
  */
208
- export function isKnownObservedStatus(status: string): status is ToolServerObservedStatus {
208
+ function isKnownObservedStatus(status: string): status is ToolServerObservedStatus {
209
209
  return (KNOWN_OBSERVED_STATUSES as readonly string[]).includes(status)
210
210
  }
211
211
 
@@ -12,6 +12,7 @@
12
12
  import { computed, ref } from 'vue'
13
13
  import type { ConsensusGroup, ConsensusStrategy } from '~/types/consensus'
14
14
  import { isSelectable } from '~/stores/models'
15
+ import { uid } from '~/utils/catalog'
15
16
 
16
17
  const { t } = useI18n()
17
18
  const groups = useConsensusGroupsStore()
@@ -52,10 +53,6 @@ interface EditorState {
52
53
  const editor = ref<EditorState | null>(null)
53
54
  const busy = ref(false)
54
55
 
55
- function uid(prefix: string) {
56
- return `${prefix}_${Math.random().toString(36).slice(2, 9)}`
57
- }
58
-
59
56
  /** A fresh group starts as a gated two-model panel — the shape the feature is for. */
60
57
  function startCreate() {
61
58
  editor.value = {
@@ -34,7 +34,6 @@ describe('narrowingOptionsFor', () => {
34
34
  describe('INHERIT_RULE', () => {
35
35
  it('is a non-empty value the select can carry as an item', () => {
36
36
  expect(INHERIT_RULE).not.toBe('')
37
- expect(INHERIT_RULE.length).toBeGreaterThan(0)
38
37
  })
39
38
 
40
39
  // It is also not one of the rules, or clearing a row would be indistinguishable from setting it.
@@ -5,7 +5,8 @@
5
5
  // synced skill catalog a pipeline `skill` step picks from. Mirrors the fragment library's
6
6
  // repo-sources UI; when the GitHub App is connected the user searches a repo + browses to the
7
7
  // skills directory, otherwise the manual owner/name/dir fields are the fallback.
8
- import { computed, reactive, ref, watch } from 'vue'
8
+ import { computed, ref, watch } from 'vue'
9
+ import { useBusyRows } from '~/composables/useBusyRows'
9
10
  import type { GitHubAvailableRepo } from '~/types/domain'
10
11
  import { useSkillLibrary } from '~/stores/skillLibrary'
11
12
  import { SKILL_GROUP_LABEL_KEYS } from '~/utils/skills'
@@ -36,17 +37,7 @@ watch(
36
37
  const githubReady = computed(() => github.available === true && github.connected)
37
38
 
38
39
  // Per-row in-flight tracking so only the control that triggered an action spins.
39
- const busyRows = reactive(new Set<string>())
40
- const rowBusy = (key: string) => busyRows.has(key)
41
- async function withRow(key: string, fn: () => Promise<void>) {
42
- if (busyRows.has(key)) return
43
- busyRows.add(key)
44
- try {
45
- await fn()
46
- } finally {
47
- busyRows.delete(key)
48
- }
49
- }
40
+ const { rowBusy, withRow } = useBusyRows()
50
41
 
51
42
  // ---- link a repo source ----------------------------------------------------
52
43
  const sourceRepoId = ref<number | undefined>(undefined)
@@ -82,7 +82,7 @@ export function focusLeftCard(card: Node | null, relatedTarget: EventTarget | nu
82
82
  * no selector is ever built from a string that could break one, and an id that fails simply
83
83
  * finds no anchor, which the runtime already handles as a skipped step.
84
84
  */
85
- export const TARGET_ID_PATTERN = /^[a-z0-9-]+$/
85
+ const TARGET_ID_PATTERN = /^[a-z0-9-]+$/
86
86
 
87
87
  /** Is this a well-formed anchor id, i.e. safe to put in a selector? */
88
88
  export function isSafeTargetId(id: string): boolean {
@@ -129,7 +129,7 @@ export function resolveSkip(
129
129
  * step's target id, since the same id (`task-card`) is a canvas node on the board and a plain
130
130
  * list row in a panel.
131
131
  */
132
- export const BOARD_NODE_SELECTOR = '.vue-flow__node'
132
+ const BOARD_NODE_SELECTOR = '.vue-flow__node'
133
133
 
134
134
  /** The part of an element the reveal path needs: ancestry, and that ancestor's node id. */
135
135
  interface RevealNode {
@@ -27,11 +27,6 @@ describe('withoutUndefinedQueryParams', () => {
27
27
  expect(out.queryParams).toEqual({ blockId: 'blk_1', page: 0, all: false, q: '' })
28
28
  })
29
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
30
  it('leaves other request params untouched', () => {
36
31
  const params = {
37
32
  pathPrefix: '/workspaces/ws_1',
@@ -0,0 +1,22 @@
1
+ import { reactive } from 'vue'
2
+
3
+ /**
4
+ * A per-row in-flight guard for lists whose rows each carry their own action buttons: a row's
5
+ * action runs at most once at a time, and the button it belongs to reads `rowBusy(key)` for its
6
+ * spinner. Keys are caller-chosen (`sync:<id>`, `unlink:<id>`), so one row can hold several
7
+ * independent actions.
8
+ */
9
+ export function useBusyRows() {
10
+ const busyRows = reactive(new Set<string>())
11
+ const rowBusy = (key: string) => busyRows.has(key)
12
+ async function withRow(key: string, fn: () => Promise<void>) {
13
+ if (busyRows.has(key)) return
14
+ busyRows.add(key)
15
+ try {
16
+ await fn()
17
+ } finally {
18
+ busyRows.delete(key)
19
+ }
20
+ }
21
+ return { rowBusy, withRow }
22
+ }
@@ -35,11 +35,6 @@ const resizingId = ref<string | null>(null)
35
35
  /** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
36
36
  export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
37
37
 
38
- /** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
39
- export function resizeCursor(edge: ResizeEdge): string {
40
- return HANDLES[edge].cursor
41
- }
42
-
43
38
  /**
44
39
  * Pointer-driven resizing for containers (service frames and modules) by dragging any border or
45
40
  * corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
@@ -1,7 +1,7 @@
1
1
  import type { LodLevel } from '~/types/domain'
2
2
 
3
3
  /** The LOD scale, shallow → deep. Index order lets callers ask "is at least". */
4
- export const LOD_ORDER: LodLevel[] = ['far', 'mid', 'close', 'steps', 'subtasks']
4
+ const LOD_ORDER: LodLevel[] = ['far', 'mid', 'close', 'steps', 'subtasks']
5
5
 
6
6
  /** Map a raw zoom factor to a level-of-detail bucket. Shared by the main board
7
7
  * and the drill-down focus view so both honour the same thresholds.
@@ -91,7 +91,7 @@ const COMPONENTS: Record<InspectorPanelId, Component> = {
91
91
  }
92
92
 
93
93
  /** The built-in `PanelEntry`s: each spec's gating/order + its wrapped component. */
94
- export const INSPECTOR_PANEL_ENTRIES: PanelEntry<Block>[] = INSPECTOR_PANEL_SPECS.map((spec) => ({
94
+ const INSPECTOR_PANEL_ENTRIES: PanelEntry<Block>[] = INSPECTOR_PANEL_SPECS.map((spec) => ({
95
95
  id: spec.id,
96
96
  order: spec.order,
97
97
  when: spec.when,
@@ -144,9 +144,10 @@ const BUILT_IN_RESULT_VIEWS: Record<ResultViewId, Component> = {
144
144
  * The built-in windows as slot entries, derived from `RESULT_VIEW_IDS` so the slot order matches
145
145
  * the canonical id order. Exhaustiveness is guaranteed by {@link BUILT_IN_RESULT_VIEWS}'s type.
146
146
  */
147
- export const RESULT_VIEW_CONTRIBUTIONS: readonly ResultViewContribution[] = RESULT_VIEW_IDS.map(
148
- (id) => ({ id, component: BUILT_IN_RESULT_VIEWS[id] }),
149
- )
147
+ const RESULT_VIEW_CONTRIBUTIONS: readonly ResultViewContribution[] = RESULT_VIEW_IDS.map((id) => ({
148
+ id,
149
+ component: BUILT_IN_RESULT_VIEWS[id],
150
+ }))
150
151
 
151
152
  /**
152
153
  * The first-party result-views module: contributes every built-in window to the
@@ -1,10 +1,4 @@
1
- import type {
2
- BlockType,
3
- CreateTaskType,
4
- FrameRepoType,
5
- TaskTypeFields,
6
- Block,
7
- } from '~/types/domain'
1
+ import type { CreateTaskType, FrameRepoType, TaskTypeFields, Block } from '~/types/domain'
8
2
  import { useWorkspaceStore } from '~/stores/workspace'
9
3
  import type { BoardWriteContext } from './context'
10
4
 
@@ -19,12 +13,6 @@ import type { BoardWriteContext } from './context'
19
13
  export function createBoardMutations(ctx: BoardWriteContext) {
20
14
  const { getBlock, upsert, api, present } = ctx
21
15
 
22
- async function addBlock(type: BlockType, position: { x: number; y: number }): Promise<Block> {
23
- const block = await api.addFrame(useWorkspaceStore().requireId(), { type, position })
24
- upsert(block)
25
- return block
26
- }
27
-
28
16
  /**
29
17
  * Import an existing GitHub repo (the App is installed + it's projected) as a
30
18
  * service frame, with no bootstrap run. The backend links the repo to the new
@@ -164,7 +152,6 @@ export function createBoardMutations(ctx: BoardWriteContext) {
164
152
  }
165
153
 
166
154
  return {
167
- addBlock,
168
155
  addServiceFromRepo,
169
156
  addTask,
170
157
  addModule,
@@ -1,6 +1,7 @@
1
1
  import { ref } from 'vue'
2
2
  import type { AgentContextSnapshot, AgentSearchQuery } from '~/types/execution'
3
3
  import { useSingleFlight } from '~/composables/useSingleFlight'
4
+ import { withFlag } from './withFlag'
4
5
 
5
6
  /** What the two reads need from the store: the workspace binding, nothing else. */
6
7
  export interface AgentContextSinkDeps {
@@ -10,14 +11,6 @@ export interface AgentContextSinkDeps {
10
11
  fetchSearchQueries: (executionId: string) => Promise<{ searchQueries: AgentSearchQuery[] }>
11
12
  }
12
13
 
13
- /** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
14
- function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boolean) {
15
- const next = new Set(set.value)
16
- if (on) next.add(key)
17
- else next.delete(key)
18
- set.value = next
19
- }
20
-
21
14
  /**
22
15
  * The observability store's AGENT-CONTEXT and SEARCH-QUERY sinks, extracted as one cohesive pair:
23
16
  * both are per-dispatch records the drill-down panel loads on open, neither is pushed live, and
@@ -1,6 +1,7 @@
1
1
  import { ref } from 'vue'
2
2
  import type { RunToolCallFailures, RunToolCallTrajectory } from '~/types/execution'
3
3
  import { useSingleFlight } from '~/composables/useSingleFlight'
4
+ import { withFlag } from './withFlag'
4
5
 
5
6
  // The observability store's TOOL-CALL sink, extracted whole because it is one concern with two
6
7
  // reads and its own coherence rule between them.
@@ -40,14 +41,6 @@ export const EMPTY_TRAJECTORY: RunToolCallTrajectory = Object.freeze({
40
41
  truncated: false,
41
42
  })
42
43
 
43
- /** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
44
- function withFlag(set: ReturnType<typeof ref<Set<string>>>, key: string, on: boolean) {
45
- const next = new Set(set.value)
46
- if (on) next.add(key)
47
- else next.delete(key)
48
- set.value = next
49
- }
50
-
51
44
  export function createToolCallSinkState(deps: ToolCallSinkDeps) {
52
45
  /**
53
46
  * One in-flight read per (sink, run). Both loads below fire on the panel OPENING, and the panel
@@ -0,0 +1,9 @@
1
+ import type { Ref } from 'vue'
2
+
3
+ /** Add or remove a key from a reactive `Set` ref, replacing it so the reactivity fires. */
4
+ export function withFlag(set: Ref<Set<string>>, key: string, on: boolean) {
5
+ const next = new Set(set.value)
6
+ if (on) next.add(key)
7
+ else next.delete(key)
8
+ set.value = next
9
+ }
@@ -92,6 +92,10 @@ export type {
92
92
  BugFishingFindingKind,
93
93
  BugFishingConfidence,
94
94
  BugFishingSpawn,
95
+ BugFishingTerritory,
96
+ BugFishingPlan,
97
+ BugFishingUnfishedCell,
98
+ BugFishingCoverage,
95
99
  AgentEffortReport,
96
100
  FragmentAdherence,
97
101
  FragmentAdherenceItem,
@@ -30,7 +30,7 @@ export type BlockMeasurements = {
30
30
  rectFor: (el: Element) => DOMRect
31
31
  }
32
32
 
33
- export const BLOCK_ID_ATTRIBUTE = 'data-block-id'
33
+ const BLOCK_ID_ATTRIBUTE = 'data-block-id'
34
34
 
35
35
  export function measureBlocks(root: ParentNode = document): BlockMeasurements {
36
36
  let elements: Map<string, HTMLElement> | null = null
@@ -34,7 +34,7 @@ export type WakeGate = {
34
34
  * instead of every frame, while a change that really did move a card is on screen well inside
35
35
  * the window a reader would notice.
36
36
  */
37
- export const RENDER_WAKE_INTERVAL_MS = 250
37
+ const RENDER_WAKE_INTERVAL_MS = 250
38
38
 
39
39
  export function createWakeGate(options: {
40
40
  /** Raise the pulse. */
@@ -15,7 +15,7 @@ import {
15
15
  } from '@cat-factory/contracts'
16
16
 
17
17
  /** Simple unique id helper (fine for a client-only prototype). */
18
- export function uid(prefix = 'id'): string {
18
+ export function uid(prefix: string): string {
19
19
  return `${prefix}_${Math.random().toString(36).slice(2, 9)}`
20
20
  }
21
21
 
@@ -1203,16 +1203,3 @@ export const FORK_DECISION_META = {
1203
1203
  icon: 'i-lucide-git-fork',
1204
1204
  color: '#a78bfa',
1205
1205
  }
1206
-
1207
- /**
1208
- * Whether a Coder step has the Follow-up companion enabled, given the pipeline's per-step
1209
- * `followUps` toggle at index `i`. Enabled by default on a `coder` step (only `false`
1210
- * disables it); ignored on other kinds.
1211
- */
1212
- export function followUpCompanionEnabled(
1213
- kind: string,
1214
- followUps: (boolean | null)[] | undefined,
1215
- i: number,
1216
- ): boolean {
1217
- return kind === 'coder' && followUps?.[i] !== false
1218
- }
@@ -1,7 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import type { DescriptorField } from '~/types/domain'
3
3
  import {
4
- defaultDescriptorValues,
5
4
  descriptorFormRows,
6
5
  descriptorGroupValue,
7
6
  setDescriptorCheckbox,
@@ -14,50 +13,6 @@ const field = (over: Partial<DescriptorField> & Pick<DescriptorField, 'key'>): D
14
13
  ...over,
15
14
  })
16
15
 
17
- describe('defaultDescriptorValues', () => {
18
- it('seeds each declared default in its own contract shape', () => {
19
- // The form model is the wire shape, so a default has to arrive typed: the shared validator
20
- // refuses a `'3'` where a `number` field is declared, and would refuse it at create time too.
21
- expect(
22
- defaultDescriptorValues([
23
- field({ key: 'style', type: 'select', default: 'collection' }),
24
- field({ key: 'depth', type: 'number', default: '3' }),
25
- field({ key: 'gate', type: 'checkbox', default: 'true' }),
26
- field({ key: 'ops', type: 'checkbox-group', defaultValues: ['create', 'list'] }),
27
- field({ key: 'dir', type: 'path', default: 'docs' }),
28
- ]),
29
- ).toEqual({
30
- style: 'collection',
31
- depth: 3,
32
- gate: true,
33
- ops: ['create', 'list'],
34
- dir: 'docs',
35
- })
36
- })
37
-
38
- it('leaves a field with no meaningful default ABSENT rather than blank', () => {
39
- // Absent is what validation reads as unset, so seeding `''`/`[]`/`false` would both freeze an
40
- // empty answer and (for a required field) look filled to nothing that checks it.
41
- expect(
42
- defaultDescriptorValues([
43
- field({ key: 'entity', type: 'text' }),
44
- field({ key: 'notes', type: 'textarea', default: '' }),
45
- field({ key: 'gate', type: 'checkbox' }),
46
- field({ key: 'gateOff', type: 'checkbox', default: 'false' }),
47
- field({ key: 'ops', type: 'checkbox-group', defaultValues: [] }),
48
- field({ key: 'depth', type: 'number', default: 'not-a-number' }),
49
- ]),
50
- ).toEqual({})
51
- })
52
-
53
- it('copies a multi-select default, so editing the form cannot mutate the descriptor', () => {
54
- const ops = field({ key: 'ops', type: 'checkbox-group', defaultValues: ['create'] })
55
- const seeded = defaultDescriptorValues([ops])
56
- ;(seeded.ops as string[]).push('delete')
57
- expect(ops.defaultValues).toEqual(['create'])
58
- })
59
- })
60
-
61
16
  describe('setDescriptorValue', () => {
62
17
  it('drops a value the shared rules read as unset rather than freezing it', () => {
63
18
  // Absent is what `validateDescriptorFields` treats as unfilled and what
@@ -1,4 +1,4 @@
1
- import { descriptorFieldDefaults, descriptorFieldSections } from '@cat-factory/contracts'
1
+ import { descriptorFieldSections } from '@cat-factory/contracts'
2
2
  import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } from '~/types/domain'
3
3
 
4
4
  // Form-side helpers over the shared descriptor-field vocabulary (`contracts/src/form-fields.ts`),
@@ -11,18 +11,6 @@ import type { DescriptorField, DescriptorFieldValue, DescriptorFieldValues } fro
11
11
  // inside the SFC, so the mutation rules a wrong answer would freeze on an entity are unit-testable
12
12
  // without mounting a component.
13
13
 
14
- /**
15
- * The initial values a field list implies, for seeding a freshly opened form. A repo-detection
16
- * probe's prefill and the user's own edits layer on top.
17
- *
18
- * The SHARED helper, not a form-side copy: the server folds the same defaults in at the creation
19
- * door (`withDescriptorFieldDefaults`), so a duplicate here would be the drift that made a headless
20
- * caller and this form disagree about what a descriptor's default means.
21
- */
22
- export function defaultDescriptorValues(fields: readonly DescriptorField[]): DescriptorFieldValues {
23
- return descriptorFieldDefaults(fields)
24
- }
25
-
26
14
  /**
27
15
  * A value that must stay ABSENT from the bag rather than freeze on the entity: an unchecked
28
16
  * (`false`) checkbox, a blank string, an empty multi-select, or a number that is not one (a
package/app/utils/dnd.ts CHANGED
@@ -1,29 +1,5 @@
1
- import type { BlockType } from '~/types/domain'
2
-
3
- /** MIME-ish key used to carry palette payloads across the HTML5 DnD boundary. */
4
- export const DND_MIME = 'application/agent-board'
5
-
6
- export type DndPayload =
7
- | { kind: 'block'; blockType: BlockType }
8
- | { kind: 'pipeline'; pipelineId: string }
9
-
10
- export function setDndPayload(event: DragEvent, payload: DndPayload) {
11
- event.dataTransfer?.setData(DND_MIME, JSON.stringify(payload))
12
- if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy'
13
- }
14
-
15
- export function readDndPayload(event: DragEvent): DndPayload | null {
16
- const raw = event.dataTransfer?.getData(DND_MIME)
17
- if (!raw) return null
18
- try {
19
- return JSON.parse(raw) as DndPayload
20
- } catch {
21
- return null
22
- }
23
- }
24
-
25
1
  /** Walk up from an event's target to find the block it landed on, if any. Works for any
26
- * DOM event (drop, double-click, …) only `event.target` is read. */
2
+ * DOM event (double-click, context menu, …): only `event.target` is read. */
27
3
  export function blockIdFromEvent(event: Event): string | null {
28
4
  const el = (event.target as HTMLElement | null)?.closest('[data-block-id]')
29
5
  return el?.getAttribute('data-block-id') ?? null
@@ -79,7 +79,7 @@ export const INITIATIVE_ATTENTION_ICONS: Record<InitiativeAttentionKind, string>
79
79
  * interview park is already owned by the planning window, behind the differently-worded "Answer
80
80
  * planning questions".
81
81
  */
82
- export const INTERVIEW_GATE_RESULT_VIEW = 'initiative-planning'
82
+ const INTERVIEW_GATE_RESULT_VIEW = 'initiative-planning'
83
83
 
84
84
  /**
85
85
  * The block's parked approval that is a PLAN REVIEW — the planner's human gate (`pl_initiative`
@@ -41,7 +41,7 @@ export type SettlingLoop = {
41
41
  * unchanged frame would miss every animation. Four frames (~66ms at 60Hz) clears that gap
42
42
  * while keeping a false wake-up cheap.
43
43
  */
44
- export const DEFAULT_SETTLE_FRAMES = 4
44
+ const DEFAULT_SETTLE_FRAMES = 4
45
45
 
46
46
  export function createSettlingLoop(options: {
47
47
  /** Runs one frame; returns whether it changed anything the user can see. */
@@ -1,14 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import { headerDistanceSq, type Rect } from './taskExpansionRanking'
3
3
 
4
- /** Rank a set of cards against a screen centre, best (would-expand) first. */
5
- function rank(cards: Record<string, Rect>, cx: number, cy: number): string[] {
6
- return Object.entries(cards)
7
- .map(([id, rect]) => ({ id, dist: headerDistanceSq(rect, cx, cy) }))
8
- .sort((a, b) => a.dist - b.dist)
9
- .map((c) => c.id)
10
- }
11
-
12
4
  describe('headerDistanceSq', () => {
13
5
  it('measures from the centre of the card top edge', () => {
14
6
  const r: Rect = { left: 0, right: 100, top: 200, bottom: 600 }
@@ -25,27 +17,26 @@ describe('headerDistanceSq', () => {
25
17
  })
26
18
  })
27
19
 
28
- describe('ranking', () => {
29
- // The regression from the screenshot: a tall card parked at the top of the screen
30
- // expands its pipeline down past the centre, so its body covers the centre. A compact
31
- // card whose header sits right at the centre must still win the one you're looking at.
20
+ describe('ordering by header distance', () => {
21
+ // `useTaskExpansion` sorts candidates by this measure, so the card with the smaller value is
22
+ // the one that expands. The regression from the screenshot: a tall card parked at the top of
23
+ // the screen expands its pipeline down past the centre, so its body covers the centre. A
24
+ // compact card whose header sits right at the centre must still win.
32
25
  it('prefers the card whose header is at the centre over a tall card bleeding down from the top', () => {
33
26
  const top: Rect = { left: 0, right: 200, top: 30, bottom: 700 } // header far up, body covers centre
34
27
  const here: Rect = { left: 0, right: 200, top: 320, bottom: 520 } // header at the centre
35
- expect(rank({ top, here }, 100, 340)).toEqual(['here', 'top'])
36
- // document order can't flip the winner
37
- expect(rank({ here, top }, 100, 340)).toEqual(['here', 'top'])
28
+ expect(headerDistanceSq(here, 100, 340)).toBeLessThan(headerDistanceSq(top, 100, 340))
38
29
  })
39
30
 
40
31
  it('ranks by the header nearest the centre regardless of expansion state', () => {
41
32
  const above: Rect = { left: 0, right: 200, top: 100, bottom: 800 }
42
33
  const below: Rect = { left: 0, right: 200, top: 360, bottom: 420 }
43
- expect(rank({ above, below }, 100, 320)).toEqual(['below', 'above'])
34
+ expect(headerDistanceSq(below, 100, 320)).toBeLessThan(headerDistanceSq(above, 100, 320))
44
35
  })
45
36
 
46
37
  it('uses horizontal offset to break a vertical tie', () => {
47
38
  const near: Rect = { left: 0, right: 100, top: 100, bottom: 200 }
48
39
  const far: Rect = { left: 400, right: 500, top: 100, bottom: 200 }
49
- expect(rank({ far, near }, 80, 100)).toEqual(['near', 'far'])
40
+ expect(headerDistanceSq(near, 80, 100)).toBeLessThan(headerDistanceSq(far, 80, 100))
50
41
  })
51
42
  })
@@ -188,7 +188,7 @@ export const TARGET_IDLE_INTERVAL_MS = 400
188
188
  * two means "mostly visible" for a small control and "filling a good part of the screen" for a
189
189
  * large one, which is the same judgement in both cases.
190
190
  */
191
- export const MIN_VISIBLE_RATIO = 0.5
191
+ const MIN_VISIBLE_RATIO = 0.5
192
192
 
193
193
  /** Deterministic tour-list order: `order`, then `id`. */
194
194
  export function sortTours(tours: readonly TutorialTour[]): TutorialTour[] {
@@ -27,7 +27,6 @@ describe('resolveUiMode', () => {
27
27
  it('falls back to the stored choice, then to the default', () => {
28
28
  expect(resolveUiMode(null, 'advanced')).toBe('advanced')
29
29
  expect(resolveUiMode(null, null)).toBe(DEFAULT_UI_MODE)
30
- expect(DEFAULT_UI_MODE).toBe('basic')
31
30
  })
32
31
 
33
32
  it('caps an intake role at basic, above BOTH the env pin and the stored choice', () => {
@@ -28,7 +28,7 @@ export type UiRole = (typeof UI_ROLES)[number]
28
28
  * The first-run prompt is offered once per session until it is answered (see `stores/uiRole.ts`),
29
29
  * and closing it leaves the whole product in place rather than guessing a narrower persona.
30
30
  */
31
- export const DEFAULT_UI_ROLE: UiRole = 'engineer'
31
+ const DEFAULT_UI_ROLE: UiRole = 'engineer'
32
32
 
33
33
  /**
34
34
  * How much of the SPA a role is offered.