@cat-factory/app 0.79.1 → 0.80.0

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 (35) hide show
  1. package/app/components/board/BoardCanvas.vue +7 -1
  2. package/app/components/bootstrap/BootstrapModal.vue +18 -0
  3. package/app/components/documents/TaskContextDocs.vue +10 -7
  4. package/app/components/fragments/FragmentLibraryManager.vue +2 -10
  5. package/app/components/github/AddServiceFromRepoModal.vue +8 -1
  6. package/app/components/panels/InspectorPanel.vue +35 -18
  7. package/app/components/panels/inspector/ContainerSummary.vue +8 -2
  8. package/app/components/panels/inspector/EpicChildren.vue +9 -7
  9. package/app/components/panels/inspector/FrontendConfig.vue +175 -248
  10. package/app/components/panels/inspector/InspectorSection.vue +60 -0
  11. package/app/components/panels/inspector/ServiceConnections.vue +10 -10
  12. package/app/components/panels/inspector/ServiceFragments.vue +14 -9
  13. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +8 -7
  14. package/app/components/panels/inspector/ServiceTestConfig.vue +45 -57
  15. package/app/components/panels/inspector/TaskAgentConfig.vue +8 -5
  16. package/app/components/panels/inspector/TaskDependencies.vue +12 -8
  17. package/app/components/panels/inspector/TaskEstimateBadge.vue +9 -8
  18. package/app/components/panels/inspector/TaskExecution.vue +9 -2
  19. package/app/components/panels/inspector/TaskRunSettings.vue +13 -2
  20. package/app/components/panels/inspector/TaskStructure.vue +9 -2
  21. package/app/components/tasks/TaskContextIssues.vue +10 -7
  22. package/app/composables/useFramePlacement.ts +91 -0
  23. package/app/stores/board.ts +7 -1
  24. package/app/stores/fragmentLibrary.ts +4 -4
  25. package/app/utils/framePlacement.spec.ts +73 -0
  26. package/app/utils/framePlacement.ts +103 -0
  27. package/i18n/locales/en.json +25 -3
  28. package/i18n/locales/es.json +25 -3
  29. package/i18n/locales/fr.json +25 -3
  30. package/i18n/locales/he.json +25 -3
  31. package/i18n/locales/ja.json +25 -3
  32. package/i18n/locales/pl.json +25 -3
  33. package/i18n/locales/tr.json +25 -3
  34. package/i18n/locales/uk.json +25 -3
  35. package/package.json +2 -2
@@ -10,6 +10,7 @@ import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
10
10
  import { useTaskExpansion } from '~/composables/useTaskExpansion'
11
11
  import { useBlockDrag } from '~/composables/useBlockDrag'
12
12
  import { useFrameStacking } from '~/composables/useFrameStacking'
13
+ import { useFramePlacement } from '~/composables/useFramePlacement'
13
14
  import { useViewport } from '~/composables/useViewport'
14
15
  import { boardPanMode } from '~/utils/boardPanMode'
15
16
 
@@ -24,6 +25,7 @@ const { t } = useI18n()
24
25
  const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
25
26
  const { draggingId } = useBlockDrag()
26
27
  const { hoveredFrameId } = useFrameStacking()
28
+ const { freeFramePosition, focusFrame } = useFramePlacement()
27
29
  // Touch drives the canvas gestures: a touch-capable surface needs one-finger pan.
28
30
  // We gate on `hasTouch` (any-pointer: coarse), not `isTouch` (the *primary* pointer),
29
31
  // so a touchscreen laptop / 2-in-1 — whose primary pointer is the trackpad — still
@@ -121,10 +123,14 @@ async function onDrop(event: DragEvent) {
121
123
  if (!payload) return
122
124
 
123
125
  if (payload.kind === 'block') {
124
- const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
126
+ // Drop where the cursor is, but nudge off any existing frame it lands on so a new
127
+ // frame never overlaps a neighbour; then centre the camera on it.
128
+ const dropped = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
129
+ const position = freeFramePosition({ near: dropped })
125
130
  try {
126
131
  const block = await board.addBlock(payload.blockType, position)
127
132
  ui.select(block.id)
133
+ await focusFrame(block.id)
128
134
  } catch {
129
135
  toast.add({
130
136
  title: t('board.canvas.addBlockFailedTitle'),
@@ -13,7 +13,9 @@ const ui = useUiStore()
13
13
  const bootstrap = useBootstrapStore()
14
14
  const agentRuns = useAgentRunsStore()
15
15
  const github = useGitHubStore()
16
+ const board = useBoardStore()
16
17
  const toast = useToast()
18
+ const { freeFramePosition, focusFrame } = useFramePlacement()
17
19
  const { t } = useI18n()
18
20
  const { confirmAction, toastDone } = useConfirmAction()
19
21
 
@@ -242,6 +244,22 @@ async function launch() {
242
244
  instructions.value = ''
243
245
  // Reset the repo role too, so a later bootstrap doesn't silently inherit this one's type.
244
246
  selectedType.value = 'service'
247
+ // The provisional frame arrived (bootstrap() refreshed the board). Re-home it to
248
+ // free space so it never overlaps an existing service — the backend places it on a
249
+ // fixed diagonal stagger that can land on top of a large neighbour — then centre the
250
+ // camera on it. Best-effort: the run has already started, so a placement hiccup must
251
+ // NOT surface a bootstrap-failed toast or leave the dialog open — swallow it here
252
+ // rather than letting it reach the outer catch.
253
+ if (job.blockId && board.getBlock(job.blockId)) {
254
+ const id = job.blockId
255
+ try {
256
+ const position = freeFramePosition({ size: board.containerSize(id), exclude: id })
257
+ await board.moveBlock(id, position)
258
+ await focusFrame(id)
259
+ } catch {
260
+ // Placement is cosmetic; the run is tracked on the board regardless.
261
+ }
262
+ }
245
263
  // The run is now tracked on the board, so get out of the way: close the
246
264
  // dialog as soon as bootstrapping has actually started.
247
265
  ui.closeBootstrap()
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { DropdownMenuItem } from '@nuxt/ui'
3
3
  import type { Block, DocumentSourceKind } from '~/types/domain'
4
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
4
5
 
5
6
  // Documents (from any source) attached to a task as agent context, shown inside
6
7
  // the InspectorPanel. Linked docs are fed to agents during execution (see the
@@ -51,17 +52,19 @@ const attachMenu = computed<DropdownMenuItem[][]>(() => {
51
52
  </script>
52
53
 
53
54
  <template>
54
- <div v-if="documents.available" class="space-y-2">
55
- <div class="flex items-center justify-between">
56
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
57
- {{ t('documents.taskDocs.heading') }}
58
- </span>
55
+ <InspectorSection
56
+ v-if="documents.available"
57
+ :title="t('documents.taskDocs.heading')"
58
+ :hint="t('documents.taskDocs.hint')"
59
+ :count="linked.length"
60
+ >
61
+ <template #actions>
59
62
  <UDropdownMenu :items="attachMenu" :content="{ side: 'bottom', align: 'end' }">
60
63
  <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">{{
61
64
  t('documents.taskDocs.attach')
62
65
  }}</UButton>
63
66
  </UDropdownMenu>
64
- </div>
67
+ </template>
65
68
 
66
69
  <div v-if="linked.length" class="space-y-1">
67
70
  <a
@@ -82,5 +85,5 @@ const attachMenu = computed<DropdownMenuItem[][]>(() => {
82
85
  <p v-else class="text-[11px] text-slate-500">
83
86
  {{ t('documents.taskDocs.empty') }}
84
87
  </p>
85
- </div>
88
+ </InspectorSection>
86
89
  </template>
@@ -298,9 +298,7 @@ async function checkSource(id: string) {
298
298
  try {
299
299
  const status = await library.checkSource(id)
300
300
  toast.add({
301
- title: status.changed
302
- ? t('fragments.toast.changesAvailable', { count: status.changedCount }, status.changedCount)
303
- : t('fragments.toast.upToDate'),
301
+ title: status.changed ? t('fragments.toast.changesAvailable') : t('fragments.toast.upToDate'),
304
302
  icon: status.changed ? 'i-lucide-bell-dot' : 'i-lucide-check',
305
303
  })
306
304
  } catch (e) {
@@ -585,13 +583,7 @@ async function unlinkSource(id: string) {
585
583
  variant="subtle"
586
584
  class="ms-auto"
587
585
  >
588
- {{
589
- t(
590
- 'fragments.sources.changes',
591
- { count: library.sourceChanges[s.id] },
592
- library.sourceChanges[s.id] ?? 0,
593
- )
594
- }}
586
+ {{ t('fragments.sources.changes') }}
595
587
  </UBadge>
596
588
  <div class="ms-auto flex gap-1">
597
589
  <UButton
@@ -28,6 +28,7 @@ const ui = useUiStore()
28
28
  const github = useGitHubStore()
29
29
  const board = useBoardStore()
30
30
  const toast = useToast()
31
+ const { freeFramePosition, focusFrame } = useFramePlacement()
31
32
 
32
33
  const open = computed({
33
34
  get: () => ui.addServiceOpen,
@@ -218,9 +219,14 @@ async function add() {
218
219
  directory: isMonorepo.value ? selectedDirectory.value : undefined,
219
220
  isMonorepo: isMonorepo.value,
220
221
  type: selectedType.value,
222
+ // Place the imported frame in free space (centred in view) instead of the
223
+ // backend's default stagger, so it never overlaps an existing service.
224
+ position: freeFramePosition(),
221
225
  })
222
226
  // Refresh the projection so the new repo↔block link is reflected locally.
223
227
  await github.load()
228
+ // Centre the camera on the newly imported service.
229
+ await focusFrame(block.id)
224
230
  configuredBlockId.value = block.id
225
231
  configuredDirectory.value = isMonorepo.value ? selectedDirectory.value : undefined
226
232
  toast.add({
@@ -364,8 +370,9 @@ function done() {
364
370
  <ServiceTestConfig
365
371
  :block="configuredBlock"
366
372
  :repo="{ githubId: selectedRepoId!, directory: configuredDirectory }"
373
+ default-open
367
374
  />
368
- <ServiceFragments :block="configuredBlock" />
375
+ <ServiceFragments :block="configuredBlock" default-open />
369
376
  </div>
370
377
 
371
378
  <div class="flex flex-wrap items-center gap-2">
@@ -442,10 +442,10 @@ const showOriginalDescription = ref(false)
442
442
  </div>
443
443
 
444
444
  <!-- task: context documents -->
445
- <TaskContextDocs v-if="isTask" :block="block" />
445
+ <TaskContextDocs v-if="isTask" :key="`context-docs-${block.id}`" :block="block" />
446
446
 
447
447
  <!-- task: context issues (tracker) -->
448
- <TaskContextIssues v-if="isTask" :block="block" />
448
+ <TaskContextIssues v-if="isTask" :key="`context-issues-${block.id}`" :block="block" />
449
449
 
450
450
  <!-- service (frame): navigate the prescriptive spec tree (+ Gherkin scenarios when
451
451
  the spec is on the repo's default branch) -->
@@ -462,35 +462,52 @@ const showOriginalDescription = ref(false)
462
462
  </UButton>
463
463
 
464
464
  <!-- service / module: tasks summary -->
465
- <ContainerSummary v-if="isContainer" :block="block" />
465
+ <ContainerSummary v-if="isContainer" :key="`container-${block.id}`" :block="block" />
466
466
  <!-- frontend (frame): build/serve/mock config + backend bindings (board links) -->
467
- <FrontendConfig v-if="isFrame && block.type === 'frontend'" :block="block" />
467
+ <FrontendConfig
468
+ v-if="isFrame && block.type === 'frontend'"
469
+ :key="`frontend-${block.id}`"
470
+ :block="block"
471
+ />
468
472
 
469
473
  <!-- service (frame): directed connections to the other services it uses (board links) -->
470
- <ServiceConnections v-if="isFrame && block.type === 'service'" :block="block" />
474
+ <ServiceConnections
475
+ v-if="isFrame && block.type === 'service'"
476
+ :key="`connections-${block.id}`"
477
+ :block="block"
478
+ />
471
479
 
472
480
  <!-- service (frame): test infra + provisioning configuration -->
473
- <ServiceTestConfig v-if="isFrame" :block="block" />
481
+ <ServiceTestConfig v-if="isFrame" :key="`test-config-${block.id}`" :block="block" />
474
482
 
475
483
  <!-- service (frame): best-practice fragments for code-aware agents -->
476
- <ServiceFragments v-if="isFrame" :block="block" />
484
+ <ServiceFragments v-if="isFrame" :key="`fragments-${block.id}`" :block="block" />
477
485
 
478
486
  <!-- service (frame): post-release-health monitor/SLO mapping -->
479
- <ServiceReleaseHealthConfig v-if="isFrame" :block="block" />
480
-
481
- <!-- task: dependencies, structure, agent config, run settings, execution -->
487
+ <ServiceReleaseHealthConfig
488
+ v-if="isFrame"
489
+ :key="`release-health-${block.id}`"
490
+ :block="block"
491
+ />
492
+
493
+ <!-- task: the live execution surface first (open by default), then the estimate,
494
+ then the collapsed configuration sections (dependencies, run settings, agent
495
+ config, structure) so a running task reads top-down without scrolling. -->
496
+ <!-- Keyed by block id so a manual collapse/expand doesn't leak across task
497
+ selections: switching tasks re-mounts each section back to its default state
498
+ (e.g. the live Execution section is open again for the newly selected task). -->
482
499
  <template v-else-if="isTask">
483
- <RecurringScheduleSettings :block="block" />
484
- <TaskDependencies :block="block" />
485
- <TaskStructure :block="block" />
486
- <TaskAgentConfig :block="block" />
487
- <TaskEstimateBadge :block="block" />
488
- <TaskRunSettings :block="block" />
489
- <TaskExecution :block="block" />
500
+ <RecurringScheduleSettings :key="`schedule-${block.id}`" :block="block" />
501
+ <TaskExecution :key="`execution-${block.id}`" :block="block" />
502
+ <TaskEstimateBadge :key="`estimate-${block.id}`" :block="block" />
503
+ <TaskDependencies :key="`deps-${block.id}`" :block="block" />
504
+ <TaskRunSettings :key="`run-settings-${block.id}`" :block="block" />
505
+ <TaskAgentConfig :key="`agent-config-${block.id}`" :block="block" />
506
+ <TaskStructure :key="`structure-${block.id}`" :block="block" />
490
507
  </template>
491
508
 
492
509
  <!-- epic: the full tree of member tasks, grouped by service → module -->
493
- <EpicChildren v-else-if="isEpic" :block="block" />
510
+ <EpicChildren v-else-if="isEpic" :key="`epic-${block.id}`" :block="block" />
494
511
 
495
512
  <!-- actions -->
496
513
  <div class="flex items-center gap-2">
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { Block } from '~/types/domain'
3
3
  import { STATUS_META } from '~/utils/catalog'
4
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
4
5
 
5
6
  const props = defineProps<{ block: Block }>()
6
7
 
@@ -20,7 +21,12 @@ function addTask() {
20
21
  </script>
21
22
 
22
23
  <template>
23
- <div class="space-y-4">
24
+ <InspectorSection
25
+ :title="t('inspector.container.title')"
26
+ :hint="t('inspector.container.hint')"
27
+ :count="tasks.length"
28
+ default-open
29
+ >
24
30
  <!-- modules (services only) -->
25
31
  <div v-if="modules.length">
26
32
  <div class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
@@ -81,5 +87,5 @@ function addTask() {
81
87
  <p v-if="isFrame" class="text-[11px] text-slate-500">
82
88
  {{ t('inspector.container.servicesHint') }}
83
89
  </p>
84
- </div>
90
+ </InspectorSection>
85
91
  </template>
@@ -2,6 +2,7 @@
2
2
  import { computed } from 'vue'
3
3
  import type { Block } from '~/types/domain'
4
4
  import { STATUS_META } from '~/utils/catalog'
5
+ import InspectorSection from '~/components/panels/inspector/InspectorSection.vue'
5
6
 
6
7
  // The epic inspector body: the full tree of member tasks (which may live under different
7
8
  // services/modules), grouped service → module → task. Each task row selects it. Membership
@@ -45,15 +46,16 @@ const groups = computed(() => {
45
46
  </script>
46
47
 
47
48
  <template>
48
- <div>
49
- <div class="mb-1 flex items-center justify-between">
50
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
51
- {{ t('inspector.epicChildren.title') }}
52
- </span>
49
+ <InspectorSection
50
+ :title="t('inspector.epicChildren.title')"
51
+ :hint="t('inspector.epicChildren.hint')"
52
+ default-open
53
+ >
54
+ <template #actions>
53
55
  <span class="text-[11px] text-slate-500">{{
54
56
  t('inspector.epicChildren.doneCount', { done, total: members.length })
55
57
  }}</span>
56
- </div>
58
+ </template>
57
59
 
58
60
  <div v-if="members.length === 0" class="text-[11px] text-slate-500">
59
61
  {{ t('inspector.epicChildren.empty') }}
@@ -92,5 +94,5 @@ const groups = computed(() => {
92
94
  </div>
93
95
  </div>
94
96
  </div>
95
- </div>
97
+ </InspectorSection>
96
98
  </template>