@cat-factory/app 0.6.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 (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. package/package.json +45 -0
@@ -0,0 +1,154 @@
1
+ import { computed, type Ref } from 'vue'
2
+ import type { Block, BlockStatus } from '~/types/domain'
3
+
4
+ /**
5
+ * Pure, read-only queries over a board's blocks. Extracted from the board store
6
+ * so the (sizeable) derivation logic — hierarchy traversal, status/progress
7
+ * rollups and container sizing — lives in one focused, independently testable
8
+ * place. The store wires these against its reactive `blocks` cache and re-exposes
9
+ * them unchanged, so callers and tests are unaffected.
10
+ */
11
+ export function useBlockQueries(blocks: Ref<Block[]>) {
12
+ const byId = computed(() => {
13
+ const map = new Map<string, Block>()
14
+ for (const b of blocks.value) map.set(b.id, b)
15
+ return map
16
+ })
17
+
18
+ function getBlock(id: string) {
19
+ return byId.value.get(id)
20
+ }
21
+
22
+ /** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
23
+ const frames = computed(() => blocks.value.filter((b) => (b.level ?? 'frame') === 'frame'))
24
+
25
+ /** Direct children of a block, in insertion order. */
26
+ function childrenOf(parentId: string) {
27
+ return blocks.value.filter((b) => b.parentId === parentId)
28
+ }
29
+
30
+ /** Tasks directly inside a container (a service or a module). */
31
+ function tasksOf(containerId: string) {
32
+ return blocks.value.filter((b) => b.parentId === containerId && b.level === 'task')
33
+ }
34
+
35
+ /** Modules (sub-frames) inside a service. */
36
+ function modulesOf(serviceId: string) {
37
+ return blocks.value.filter((b) => b.parentId === serviceId && b.level === 'module')
38
+ }
39
+
40
+ /** Tasks anywhere under a container — directly, or nested inside its modules. */
41
+ function allTasksUnder(containerId: string): Block[] {
42
+ const direct = tasksOf(containerId)
43
+ const nested = modulesOf(containerId).flatMap((m) => tasksOf(m.id))
44
+ return [...direct, ...nested]
45
+ }
46
+
47
+ /** The top-level service a block ultimately belongs to. */
48
+ function serviceOf(block: Block): Block | undefined {
49
+ let cur: Block | undefined = block
50
+ while (cur && cur.level !== 'frame') {
51
+ cur = cur.parentId ? getBlock(cur.parentId) : undefined
52
+ }
53
+ return cur
54
+ }
55
+
56
+ /** All tasks across every service (used for the dependency picker). */
57
+ const allTasks = computed(() => blocks.value.filter((b) => b.level === 'task'))
58
+
59
+ /** A task's dependencies that are not yet merged (i.e. block it from running). */
60
+ function unmetDeps(taskId: string) {
61
+ const t = getBlock(taskId)
62
+ if (!t) return [] as Block[]
63
+ return t.dependsOn
64
+ .map((id) => getBlock(id))
65
+ .filter((b): b is Block => !!b && b.status !== 'done')
66
+ }
67
+
68
+ /** A task may run only once all of its dependencies have merged. */
69
+ function isRunnable(taskId: string) {
70
+ return unmetDeps(taskId).length === 0
71
+ }
72
+
73
+ /** Container status/progress are derived from the tasks under it (containers have no PR). */
74
+ function frameProgress(frameId: string) {
75
+ const tasks = allTasksUnder(frameId)
76
+ if (tasks.length === 0) return getBlock(frameId)?.progress ?? 0
77
+ const sum = tasks.reduce((n, t) => n + (t.status === 'done' ? 1 : t.progress), 0)
78
+ return sum / tasks.length
79
+ }
80
+
81
+ /**
82
+ * A frame is a long-lived service: it never reaches a terminal "done" —
83
+ * tasks keep appearing. So its status reflects current *activity*, mapped
84
+ * onto the shared status palette but capped below `done`:
85
+ * planned → no tasks yet (empty)
86
+ * ready → has tasks but nothing active (idle / caught up / "live")
87
+ * in_progress → at least one task running or with an open PR
88
+ * blocked → at least one task needs a decision
89
+ */
90
+ function frameStatus(frameId: string): BlockStatus {
91
+ const tasks = allTasksUnder(frameId)
92
+ if (tasks.length === 0) return 'planned'
93
+ if (tasks.some((t) => t.status === 'blocked')) return 'blocked'
94
+ if (tasks.some((t) => t.status === 'in_progress' || t.status === 'pr_ready'))
95
+ return 'in_progress'
96
+ return 'ready'
97
+ }
98
+
99
+ /**
100
+ * The natural extent of a container's inner 2D canvas — the smallest size that
101
+ * still fits all its children. This is the floor a resizable frame can never be
102
+ * dragged below (so tasks/modules are never clipped).
103
+ */
104
+ function contentSize(id: string): { w: number; h: number } {
105
+ const b = getBlock(id)
106
+ const isModule = b?.level === 'module'
107
+ const TASK_W = 180
108
+ const TASK_H = 160
109
+ const headerH = isModule ? 30 : 0
110
+ let w = isModule ? 200 : 360
111
+ let inner = isModule ? 60 : 220
112
+ for (const t of tasksOf(id)) {
113
+ w = Math.max(w, t.position.x + TASK_W + 12)
114
+ inner = Math.max(inner, t.position.y + TASK_H + 12)
115
+ }
116
+ for (const m of modulesOf(id)) {
117
+ const s = containerSize(m.id)
118
+ w = Math.max(w, m.position.x + s.w + 12)
119
+ inner = Math.max(inner, m.position.y + s.h + 12)
120
+ }
121
+ return { w, h: inner + headerH }
122
+ }
123
+
124
+ /**
125
+ * Pixel size of a container's inner 2D canvas. The content extent is the floor;
126
+ * a frame the user has resized (dragging its borders) uses its stored `size`
127
+ * when that is larger, so an explicit size grows the frame but never shrinks it
128
+ * below its contents.
129
+ */
130
+ function containerSize(id: string): { w: number; h: number } {
131
+ const content = contentSize(id)
132
+ const stored = getBlock(id)?.size
133
+ if (!stored) return content
134
+ return { w: Math.max(content.w, stored.w), h: Math.max(content.h, stored.h) }
135
+ }
136
+
137
+ return {
138
+ byId,
139
+ getBlock,
140
+ frames,
141
+ allTasks,
142
+ childrenOf,
143
+ tasksOf,
144
+ modulesOf,
145
+ allTasksUnder,
146
+ serviceOf,
147
+ unmetDeps,
148
+ isRunnable,
149
+ frameProgress,
150
+ frameStatus,
151
+ contentSize,
152
+ containerSize,
153
+ }
154
+ }
@@ -0,0 +1,11 @@
1
+ import { useVueFlow } from '@vue-flow/core'
2
+
3
+ /** Stable id for the main board's Vue Flow instance. Passing this id to
4
+ * useVueFlow() from anywhere (e.g. the toolbar) accesses the same instance. */
5
+ export const BOARD_FLOW_ID = 'board'
6
+
7
+ /** Camera controls for the main board, usable outside the canvas component. */
8
+ export function useBoardFlow() {
9
+ const { fitView, zoomIn, zoomOut, viewport } = useVueFlow(BOARD_FLOW_ID)
10
+ return { fitView, zoomIn, zoomOut, viewport }
11
+ }
@@ -0,0 +1,65 @@
1
+ import type { DocumentSourceKind, TaskSourceKind } from '~/types/domain'
2
+
3
+ // Shared model + orchestration for attaching external context (imported docs and
4
+ // tracker issues) to a board block. A "pending" item is something the user has
5
+ // chosen to attach but which is only linked once the block exists — so the task
6
+ // creation popup can collect selections up front and commit them after the task
7
+ // is created. Search hits and pasted URLs carry `needsImport: true`: they are not
8
+ // yet projected locally, so they are imported (fetched + persisted) before being
9
+ // linked; already-imported items are linked directly. Reused wherever context is
10
+ // attached (the add-task popup today; the inspector can adopt it later).
11
+
12
+ export interface PendingContext {
13
+ kind: 'document' | 'task'
14
+ source: DocumentSourceKind | TaskSourceKind
15
+ /** A canonical external id (already-imported / search hit) or a pasted URL/ref. */
16
+ externalId: string
17
+ title: string
18
+ /** Secondary line: an issue status, a source label, the raw URL, … */
19
+ subtitle?: string
20
+ /** Lucide icon for the row. */
21
+ icon?: string
22
+ /** True when the item must be imported before it can be linked. */
23
+ needsImport: boolean
24
+ }
25
+
26
+ /** Stable key for a pending item, used for dedupe + selection toggles. */
27
+ export function contextKey(c: Pick<PendingContext, 'kind' | 'source' | 'externalId'>): string {
28
+ return `${c.kind}:${c.source}:${c.externalId}`
29
+ }
30
+
31
+ export function useContextLinking() {
32
+ const documents = useDocumentsStore()
33
+ const tasks = useTasksStore()
34
+
35
+ /**
36
+ * Import (when needed) then link every pending item to `blockId`. Each failure
37
+ * is counted rather than aborting the batch, so one bad attachment doesn't sink
38
+ * the rest; returns how many failed.
39
+ */
40
+ async function linkPending(blockId: string, items: PendingContext[]): Promise<number> {
41
+ let failed = 0
42
+ for (const item of items) {
43
+ try {
44
+ if (item.kind === 'document') {
45
+ const source = item.source as DocumentSourceKind
46
+ const externalId = item.needsImport
47
+ ? (await documents.importDocument(source, item.externalId)).externalId
48
+ : item.externalId
49
+ await documents.linkToBlock(blockId, source, externalId)
50
+ } else {
51
+ const source = item.source as TaskSourceKind
52
+ const externalId = item.needsImport
53
+ ? (await tasks.importTask(source, item.externalId)).externalId
54
+ : item.externalId
55
+ await tasks.linkToBlock(blockId, source, externalId)
56
+ }
57
+ } catch {
58
+ failed++
59
+ }
60
+ }
61
+ return failed
62
+ }
63
+
64
+ return { linkPending }
65
+ }
@@ -0,0 +1,26 @@
1
+ import type { Block } from '~/types/domain'
2
+
3
+ /**
4
+ * Labels a dependency block, qualifying it with its owning frame when it lives
5
+ * in a different container than the task depending on it (a "cross-frame" edge).
6
+ * Shared by the inspector and the task card so the two read identically.
7
+ */
8
+ export function useDepLabels() {
9
+ const board = useBoardStore()
10
+
11
+ /** Title of a block's parent container, if any. */
12
+ function frameTitle(b: Block): string | undefined {
13
+ return b.parentId ? board.getBlock(b.parentId)?.title : undefined
14
+ }
15
+
16
+ /**
17
+ * `Parent / Title` when `dep` lives in a different container than
18
+ * `contextParentId`, otherwise just the dependency's own title.
19
+ */
20
+ function depLabel(dep: Block, contextParentId?: string | null): string {
21
+ const f = frameTitle(dep)
22
+ return f && dep.parentId !== contextParentId ? `${f} / ${dep.title}` : dep.title
23
+ }
24
+
25
+ return { frameTitle, depLabel }
26
+ }
@@ -0,0 +1,54 @@
1
+ import { ref } from 'vue'
2
+ import type { Block } from '~/types/domain'
3
+
4
+ /**
5
+ * Pointer-driven resizing for service frames (Miro-style border drag). The drag
6
+ * delta is divided by the board zoom so the edge tracks the cursor, and the new
7
+ * size is clamped to the frame's content extent so dragging in never clips the
8
+ * tasks/modules inside. The frame grows live (the store block is mutated in place,
9
+ * which `containerSize` reads back), and the final size is persisted once on
10
+ * release rather than on every move.
11
+ */
12
+ export function useFrameResize() {
13
+ const board = useBoardStore()
14
+ const ui = useUiStore()
15
+ /** Id of the frame currently being resized, for cursor/handle styling. */
16
+ const resizingId = ref<string | null>(null)
17
+
18
+ /**
19
+ * Begin a resize from one of the frame's edges/corner. `edge` selects which
20
+ * dimensions move: `'e'` width only, `'s'` height only, `'se'` both.
21
+ */
22
+ function startResize(block: Block, e: PointerEvent, edge: 'e' | 's' | 'se') {
23
+ if (e.button !== 0) return
24
+ e.preventDefault()
25
+ e.stopPropagation()
26
+ const startX = e.clientX
27
+ const startY = e.clientY
28
+ // The content extent is the floor — never shrink a frame below its tasks.
29
+ const min = board.contentSize(block.id)
30
+ // Seed from the current rendered size so the first move doesn't jump.
31
+ const start = board.containerSize(block.id)
32
+ resizingId.value = block.id
33
+
34
+ const onMove = (ev: PointerEvent) => {
35
+ const z = ui.zoom || 1
36
+ const w = edge === 's' ? start.w : Math.max(min.w, start.w + (ev.clientX - startX) / z)
37
+ const h = edge === 'e' ? start.h : Math.max(min.h, start.h + (ev.clientY - startY) / z)
38
+ // Optimistic, local-only: mutate the cached block so the frame grows live
39
+ // without a round-trip on every pointer move.
40
+ block.size = { w: Math.round(w), h: Math.round(h) }
41
+ }
42
+ const onUp = () => {
43
+ window.removeEventListener('pointermove', onMove)
44
+ window.removeEventListener('pointerup', onUp)
45
+ resizingId.value = null
46
+ // Persist the final size once (also re-applies it as the authoritative block).
47
+ if (block.size) void board.updateBlock(block.id, { size: block.size })
48
+ }
49
+ window.addEventListener('pointermove', onMove)
50
+ window.addEventListener('pointerup', onUp)
51
+ }
52
+
53
+ return { resizingId, startResize }
54
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared contract for a dedicated result-view window (the `resultView` seam — see
3
+ * `~/utils/catalog`, `StepResultViewHost.vue`). Every such window resolves the same state
4
+ * from `ui.resultView`, closes the same way, and (when it loads data) must fetch on open.
5
+ *
6
+ * `StepResultViewHost` mounts each window FRESH every time it opens, so a per-window
7
+ * `watch` would have to be `immediate` to fetch on the initial mount — easy to forget, and
8
+ * forgetting it makes the window show an empty state for whichever navigation route didn't
9
+ * happen to warm a cache first. Centralising the contract here means a window can't drift:
10
+ * declare an `onOpen` loader and it fires immediately on mount AND on any later block switch,
11
+ * regardless of how the window was opened.
12
+ *
13
+ * A synchronous window (one that reads its data straight off the execution step, like the
14
+ * test report) simply omits `onOpen`.
15
+ */
16
+ export function useResultView(viewId: string, opts?: { onOpen?: (blockId: string) => void }) {
17
+ const ui = useUiStore()
18
+
19
+ const open = computed(() => ui.resultView?.view === viewId)
20
+ // Null whenever this window isn't the active view, so a stale id from another window's
21
+ // open can never leak into this one.
22
+ const blockId = computed(() => (open.value ? ui.resultView!.blockId : null))
23
+ const instanceId = computed(() => (open.value ? ui.resultView!.instanceId : null))
24
+ const stepIndex = computed(() => (open.value ? ui.resultView!.stepIndex : null))
25
+
26
+ function close() {
27
+ ui.closeResultView()
28
+ }
29
+
30
+ function onKey(e: KeyboardEvent) {
31
+ if (e.key === 'Escape' && open.value) close()
32
+ }
33
+ onMounted(() => window.addEventListener('keydown', onKey))
34
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
35
+
36
+ // The load-on-open contract: fire immediately on mount and on any later block switch.
37
+ if (opts?.onOpen) {
38
+ watch(
39
+ blockId,
40
+ (id) => {
41
+ if (id) opts.onOpen!(id)
42
+ },
43
+ { immediate: true },
44
+ )
45
+ }
46
+
47
+ return { open, blockId, instanceId, stepIndex, close }
48
+ }
@@ -0,0 +1,40 @@
1
+ import { useRequirementsStore } from '~/stores/requirements'
2
+ import { useClarityStore } from '~/stores/clarity'
3
+
4
+ /** The async stage an iterative reviewer gate is mid-cycle in, or null. */
5
+ export type ReviewStage = 'incorporating' | 'reviewing' | null
6
+
7
+ // Both iterative reviewer gates (`requirements-review` over a feature brief and
8
+ // `clarity-review` over a bug report) drive the same answer → incorporate → re-review
9
+ // loop, and while a review is folding answers / re-reviewing in the durable driver it
10
+ // needs NO human — so its parked approval must be SUPPRESSED on the board/inspector and a
11
+ // working indicator shown instead. This composable maps a review gate to the store that
12
+ // tracks its loop, so the board surfaces (BlockNode / TaskCard / TaskExecution) handle
13
+ // both review kinds through one helper rather than special-casing each kind separately.
14
+
15
+ export function useReviewStage() {
16
+ const requirements = useRequirementsStore()
17
+ const clarity = useClarityStore()
18
+
19
+ /**
20
+ * The background stage for a block regardless of which review kind drives it. A task is
21
+ * either a feature task (requirements review) or a bug task (clarity review), so at most
22
+ * one store has a live stage for it.
23
+ */
24
+ function stageForBlock(blockId: string): ReviewStage {
25
+ return requirements.backgroundStage(blockId) ?? clarity.backgroundStage(blockId)
26
+ }
27
+
28
+ /**
29
+ * Whether a specific review-gate approval is mid-cycle background work — keyed off the
30
+ * approval's own `agentKind` so a non-review approval on the same block is never
31
+ * suppressed by a coincidental review stage.
32
+ */
33
+ function isBackground(agentKind: string | undefined, blockId: string): boolean {
34
+ if (agentKind === 'requirements-review') return requirements.backgroundStage(blockId) != null
35
+ if (agentKind === 'clarity-review') return clarity.backgroundStage(blockId) != null
36
+ return false
37
+ }
38
+
39
+ return { stageForBlock, isBackground }
40
+ }
@@ -0,0 +1,31 @@
1
+ import type { LodLevel } from '~/types/domain'
2
+
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']
5
+
6
+ /** Map a raw zoom factor to a level-of-detail bucket. Shared by the main board
7
+ * and the drill-down focus view so both honour the same thresholds.
8
+ *
9
+ * Past `close` (where a frame opens to show its tasks) two deeper bands drill
10
+ * into an individual task: `steps` reveals its build-pipeline steps, `subtasks`
11
+ * expands each step's live todo breakdown — the spatial analogue of opening a
12
+ * task in the inspector. */
13
+ export function zoomToLod(zoom: number): LodLevel {
14
+ if (zoom < 0.6) return 'far'
15
+ if (zoom < 1.2) return 'mid'
16
+ if (zoom < 1.8) return 'close'
17
+ if (zoom < 2.4) return 'steps'
18
+ return 'subtasks'
19
+ }
20
+
21
+ /** True when `lod` is at least as deep as `min` on the LOD scale. */
22
+ export function lodAtLeast(lod: LodLevel, min: LodLevel): boolean {
23
+ return LOD_ORDER.indexOf(lod) >= LOD_ORDER.indexOf(min)
24
+ }
25
+
26
+ /** Reactive LOD bound to the global UI zoom (set by the board canvas). */
27
+ export function useSemanticZoom() {
28
+ const ui = useUiStore()
29
+ const lod = computed<LodLevel>(() => zoomToLod(ui.zoom))
30
+ return { lod, zoom: computed(() => ui.zoom) }
31
+ }
@@ -0,0 +1,233 @@
1
+ import { ref, computed, nextTick, watch } from 'vue'
2
+ import type { PipelineStep } from '~/types/execution'
3
+ import { sliceSource } from '~/utils/agentOutput'
4
+
5
+ /** A draft per-block review comment, anchored to a source range of the output. */
6
+ interface DraftComment {
7
+ srcStart: number
8
+ srcEnd: number
9
+ quotedSource: string
10
+ body: string
11
+ }
12
+
13
+ /**
14
+ * The GitHub-style approval/review state machine for a pending gate step. When the
15
+ * step's gate is pending the prose reader doubles as a review surface: the human can
16
+ * comment on individual source-mapped blocks, leave overall feedback, edit the
17
+ * conclusions in place, then Approve / Request changes / Reject. This composable owns
18
+ * all of that draft state + the in-document highlight syncing; the parent supplies the
19
+ * live step, the scroll container (for highlight lookups), the run/approval ids, and a
20
+ * `close` callback the actions invoke once they resolve.
21
+ */
22
+ export function useStepApproval(opts: {
23
+ step: () => PipelineStep | null
24
+ scrollEl: () => HTMLElement | null
25
+ instanceId: () => string | undefined
26
+ approvalId: () => string | null
27
+ approvalPending: () => boolean
28
+ companionExceeded: () => boolean
29
+ close: () => void
30
+ }) {
31
+ const execution = useExecutionStore()
32
+
33
+ const reviewComments = ref<DraftComment[]>([])
34
+ const feedback = ref('')
35
+ const submitting = ref(false)
36
+ const draftTarget = ref<{ srcStart: number; srcEnd: number; quotedSource: string } | null>(null)
37
+ const draftBody = ref('')
38
+
39
+ // "Approve with corrections" mode: a deliberate state distinct from the read-only
40
+ // review — the human edits the conclusions directly and those edits flow forward as
41
+ // the approved proposal. It CANNOT be mixed with the request-changes/comments path.
42
+ const editing = ref(false)
43
+ const draftProposal = ref('')
44
+
45
+ // Reject stops the whole run, so it's a two-step inline confirm (no native dialog).
46
+ const rejectArmed = ref(false)
47
+
48
+ const blockKey = (c: { srcStart: number; srcEnd: number }) => `${c.srcStart}:${c.srcEnd}`
49
+
50
+ /** Toggle the highlight classes on commented / selected blocks within the reader. */
51
+ function syncHighlights() {
52
+ const root = opts.scrollEl()
53
+ if (!root) return
54
+ const commented = new Set(reviewComments.value.map(blockKey))
55
+ const selected = draftTarget.value ? blockKey(draftTarget.value) : null
56
+ for (const el of Array.from(root.querySelectorAll('[data-src-start]'))) {
57
+ const key = `${el.getAttribute('data-src-start')}:${el.getAttribute('data-src-end')}`
58
+ el.classList.toggle('cf-commented', commented.has(key))
59
+ el.classList.toggle('cf-selected', key === selected)
60
+ }
61
+ }
62
+
63
+ /** Click a rendered block to start commenting on it (links keep working). */
64
+ function onProseClick(e: MouseEvent) {
65
+ if (!opts.approvalPending() || opts.companionExceeded() || editing.value) return
66
+ const target = e.target as HTMLElement
67
+ if (target.closest('a')) return
68
+ const blockEl = target.closest('[data-src-start]') as HTMLElement | null
69
+ if (!blockEl) return
70
+ const srcStart = Number(blockEl.getAttribute('data-src-start'))
71
+ const srcEnd = Number(blockEl.getAttribute('data-src-end'))
72
+ if (Number.isNaN(srcStart) || Number.isNaN(srcEnd)) return
73
+ draftTarget.value = {
74
+ srcStart,
75
+ srcEnd,
76
+ quotedSource: sliceSource(opts.step()?.output ?? '', srcStart, srcEnd),
77
+ }
78
+ draftBody.value = ''
79
+ void nextTick(syncHighlights)
80
+ }
81
+
82
+ function addDraftComment() {
83
+ if (!draftTarget.value || !draftBody.value.trim()) return
84
+ reviewComments.value.push({ ...draftTarget.value, body: draftBody.value.trim() })
85
+ draftTarget.value = null
86
+ draftBody.value = ''
87
+ void nextTick(syncHighlights)
88
+ }
89
+ function cancelDraft() {
90
+ draftTarget.value = null
91
+ draftBody.value = ''
92
+ void nextTick(syncHighlights)
93
+ }
94
+ function removeComment(idx: number) {
95
+ reviewComments.value.splice(idx, 1)
96
+ void nextTick(syncHighlights)
97
+ }
98
+
99
+ const canRequestChanges = computed(
100
+ () => !!feedback.value.trim() || reviewComments.value.length > 0,
101
+ )
102
+
103
+ // Plain approve: accept the agent's proposal verbatim and advance.
104
+ async function approve() {
105
+ const id = opts.approvalId()
106
+ if (!opts.instanceId() || !id || submitting.value) return
107
+ submitting.value = true
108
+ try {
109
+ await execution.approveStep(opts.instanceId()!, id)
110
+ opts.close()
111
+ } finally {
112
+ submitting.value = false
113
+ }
114
+ }
115
+
116
+ function startEditing() {
117
+ draftProposal.value = opts.step()?.output ?? ''
118
+ editing.value = true
119
+ // Editing and the review/reject path are mutually exclusive — clear the other.
120
+ rejectArmed.value = false
121
+ draftTarget.value = null
122
+ void nextTick(syncHighlights)
123
+ }
124
+ function cancelEditing() {
125
+ editing.value = false
126
+ draftProposal.value = ''
127
+ }
128
+ async function approveWithEdits() {
129
+ const id = opts.approvalId()
130
+ if (!opts.instanceId() || !id || submitting.value) return
131
+ submitting.value = true
132
+ try {
133
+ await execution.approveStep(opts.instanceId()!, id, draftProposal.value)
134
+ opts.close()
135
+ } finally {
136
+ submitting.value = false
137
+ }
138
+ }
139
+ async function requestChanges() {
140
+ const id = opts.approvalId()
141
+ if (!opts.instanceId() || !id || submitting.value || !canRequestChanges.value) return
142
+ submitting.value = true
143
+ try {
144
+ await execution.requestStepChanges(opts.instanceId()!, id, {
145
+ feedback: feedback.value.trim() || undefined,
146
+ comments: reviewComments.value.length
147
+ ? reviewComments.value.map((c) => ({
148
+ quotedSource: c.quotedSource,
149
+ srcStart: c.srcStart,
150
+ srcEnd: c.srcEnd,
151
+ body: c.body,
152
+ }))
153
+ : undefined,
154
+ })
155
+ opts.close()
156
+ } finally {
157
+ submitting.value = false
158
+ }
159
+ }
160
+ function armReject() {
161
+ rejectArmed.value = true
162
+ }
163
+ function disarmReject() {
164
+ rejectArmed.value = false
165
+ }
166
+ async function reject() {
167
+ const id = opts.approvalId()
168
+ if (!opts.instanceId() || !id || submitting.value) return
169
+ submitting.value = true
170
+ try {
171
+ await execution.rejectStep(opts.instanceId()!, id, feedback.value.trim() || undefined)
172
+ opts.close()
173
+ } finally {
174
+ submitting.value = false
175
+ rejectArmed.value = false
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Reset the approve-with-edits / reject sub-states so reopening the same step is
181
+ * clean (the step-change watch only fires when the step key actually changes).
182
+ */
183
+ function resetForClose() {
184
+ editing.value = false
185
+ draftProposal.value = ''
186
+ rejectArmed.value = false
187
+ }
188
+
189
+ /** Full reset of every draft when a different gate/step opens. */
190
+ function resetForStep() {
191
+ reviewComments.value = []
192
+ feedback.value = ''
193
+ draftTarget.value = null
194
+ draftBody.value = ''
195
+ rejectArmed.value = false
196
+ editing.value = false
197
+ draftProposal.value = ''
198
+ }
199
+
200
+ // Keep the in-document highlights in sync as the output renders or comments change.
201
+ watch(
202
+ [opts.approvalPending, () => opts.step()?.output, reviewComments, draftTarget],
203
+ () => void nextTick(syncHighlights),
204
+ { deep: true },
205
+ )
206
+
207
+ return {
208
+ reviewComments,
209
+ feedback,
210
+ submitting,
211
+ draftTarget,
212
+ draftBody,
213
+ editing,
214
+ draftProposal,
215
+ rejectArmed,
216
+ canRequestChanges,
217
+ syncHighlights,
218
+ onProseClick,
219
+ addDraftComment,
220
+ cancelDraft,
221
+ removeComment,
222
+ approve,
223
+ startEditing,
224
+ cancelEditing,
225
+ approveWithEdits,
226
+ requestChanges,
227
+ armReject,
228
+ disarmReject,
229
+ reject,
230
+ resetForClose,
231
+ resetForStep,
232
+ }
233
+ }