@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,78 @@
1
+ import { ref, reactive, computed, nextTick } from 'vue'
2
+ import { parseOutputOutline } from '~/utils/agentOutput'
3
+
4
+ /**
5
+ * The prose reader for an agent step's markdown output: its heading outline, the
6
+ * per-section collapse state, and the scroll-spy that keeps the ToC in sync.
7
+ * Owns the scroll container + per-section element refs the template binds; the
8
+ * details card is always the first anchor. `reset()` re-seeds (all sections
9
+ * expanded, scrolled to top) whenever a different step opens.
10
+ */
11
+ export function useStepProse(getOutput: () => string) {
12
+ const outline = computed(() => parseOutputOutline(getOutput()))
13
+ const tocSections = computed(() => outline.value.sections.filter((s) => s.depth > 0))
14
+ const hasOutput = computed(() => !!getOutput().trim())
15
+
16
+ const collapsed = reactive<Record<string, boolean>>({})
17
+ const activeId = ref<string>('step-details')
18
+ const scrollEl = ref<HTMLElement | null>(null)
19
+ const sectionEls = reactive<Record<string, HTMLElement | null>>({})
20
+
21
+ // Anchors the ToC navigates + the scroll-spy tracks: the details card first, then
22
+ // every heading section of the prose.
23
+ const anchors = computed(() => ['step-details', ...tocSections.value.map((s) => s.id)])
24
+
25
+ function toggle(id: string) {
26
+ collapsed[id] = !collapsed[id]
27
+ }
28
+ function setAll(value: boolean) {
29
+ for (const s of outline.value.sections) collapsed[s.id] = value
30
+ }
31
+ const allCollapsed = computed(
32
+ () => outline.value.sections.length > 0 && outline.value.sections.every((s) => collapsed[s.id]),
33
+ )
34
+
35
+ async function goTo(id: string) {
36
+ if (collapsed[id]) collapsed[id] = false
37
+ activeId.value = id
38
+ await nextTick()
39
+ sectionEls[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
40
+ }
41
+
42
+ function onScroll() {
43
+ const container = scrollEl.value
44
+ if (!container) return
45
+ const line = container.getBoundingClientRect().top + 80
46
+ let current = anchors.value[0] ?? 'step-details'
47
+ for (const id of anchors.value) {
48
+ const el = sectionEls[id]
49
+ if (el && el.getBoundingClientRect().top <= line) current = id
50
+ else break
51
+ }
52
+ activeId.value = current
53
+ }
54
+
55
+ // Re-seed (all sections expanded, scrolled to top) for a freshly-opened step.
56
+ function reset() {
57
+ for (const k of Object.keys(collapsed)) delete collapsed[k]
58
+ activeId.value = 'step-details'
59
+ void nextTick(() => scrollEl.value?.scrollTo({ top: 0 }))
60
+ }
61
+
62
+ return {
63
+ outline,
64
+ tocSections,
65
+ hasOutput,
66
+ collapsed,
67
+ activeId,
68
+ scrollEl,
69
+ sectionEls,
70
+ anchors,
71
+ toggle,
72
+ setAll,
73
+ allCollapsed,
74
+ goTo,
75
+ onScroll,
76
+ reset,
77
+ }
78
+ }
@@ -0,0 +1,63 @@
1
+ import { ref, computed, onMounted, onUnmounted } from 'vue'
2
+ import type { PipelineStep } from '~/types/execution'
3
+
4
+ /**
5
+ * Live elapsed-time clock for a single pipeline step. A 1s tick drives the
6
+ * counting-up duration while the step is actively running; the clock freezes at
7
+ * the step's finish, the run's failure time, or the moment it parked on a human
8
+ * (`pausedAt`) so a mid-flight step (no `finishedAt`) doesn't tick up forever.
9
+ */
10
+ export function useStepTimer(opts: {
11
+ step: () => PipelineStep | null
12
+ runFailed: () => boolean
13
+ failureAt: () => number | null | undefined
14
+ }) {
15
+ // A 1s tick so a still-running step's elapsed time counts up live while open.
16
+ const nowTick = ref(0)
17
+ let timer: ReturnType<typeof setInterval> | undefined
18
+ onMounted(() => {
19
+ nowTick.value = Date.now()
20
+ timer = setInterval(() => (nowTick.value = Date.now()), 1000)
21
+ })
22
+ onUnmounted(() => {
23
+ if (timer) clearInterval(timer)
24
+ })
25
+
26
+ // A step that is finished, failed, or parked on a human is not actively
27
+ // executing — no ticking clock or spinner. `pausedAt` is the "waiting on input"
28
+ // freeze.
29
+ const isRunning = computed(() => {
30
+ const s = opts.step()
31
+ return !!s?.startedAt && !s?.finishedAt && s?.pausedAt == null && !opts.runFailed()
32
+ })
33
+
34
+ /** Elapsed/total execution time in ms — null until the step has started. */
35
+ const durationMs = computed(() => {
36
+ const s = opts.step()
37
+ if (s?.startedAt == null) return null
38
+ // Freeze the clock once the step stops working: at its finish, else at the
39
+ // failure time once the run has failed, else at the moment it parked on a
40
+ // human (`pausedAt`). Otherwise it is live, so count up to the current tick.
41
+ const end =
42
+ s.finishedAt ??
43
+ (opts.runFailed() ? (opts.failureAt() ?? s.startedAt) : (s.pausedAt ?? nowTick.value))
44
+ return Math.max(0, end - s.startedAt)
45
+ })
46
+
47
+ const durationLabel = computed(() =>
48
+ durationMs.value == null ? null : formatDuration(durationMs.value),
49
+ )
50
+
51
+ return { isRunning, durationMs, durationLabel }
52
+ }
53
+
54
+ function formatDuration(ms: number): string {
55
+ const totalSec = Math.round(ms / 1000)
56
+ if (totalSec < 60) return `${totalSec}s`
57
+ const m = Math.floor(totalSec / 60)
58
+ const sec = totalSec % 60
59
+ if (m < 60) return sec ? `${m}m ${sec}s` : `${m}m`
60
+ const h = Math.floor(m / 60)
61
+ const min = m % 60
62
+ return min ? `${h}h ${min}m` : `${h}h`
63
+ }
@@ -0,0 +1,92 @@
1
+ import type { Ref } from 'vue'
2
+ import { onMounted, onBeforeUnmount } from 'vue'
3
+ import { useRafFn } from '@vueuse/core'
4
+ import { lodAtLeast } from '~/composables/useSemanticZoom'
5
+
6
+ type Rect = { left: number; right: number; top: number; bottom: number }
7
+
8
+ function intersects(a: Rect, b: Rect) {
9
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top
10
+ }
11
+
12
+ function sameSet(a: Set<string>, b: Set<string>) {
13
+ if (a.size !== b.size) return false
14
+ for (const id of a) if (!b.has(id)) return false
15
+ return true
16
+ }
17
+
18
+ /**
19
+ * Board-level driver deciding which task cards may expand their full pipeline list
20
+ * once zoomed in (the deep `steps`/`subtasks` bands). Two gates, recomputed every
21
+ * frame against live DOM rects so they follow pan / zoom / drag / resize:
22
+ *
23
+ * - visibility: a task expands only while its card overlaps the board viewport.
24
+ * - overlap: walking the visible candidates nearest-to-screen-centre first, a task
25
+ * expands only if its footprint doesn't collide with one already granted, so the
26
+ * centre-most task wins an overlap and the rest stay compact.
27
+ *
28
+ * Writes the permitted id set into the `taskExpansion` store; `TaskPipelineMini` reads it.
29
+ * Only tasks with a running pipeline (steps to show) are candidates — a task that
30
+ * wouldn't expand never blocks a neighbour.
31
+ */
32
+ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
33
+ const board = useBoardStore()
34
+ const execution = useExecutionStore()
35
+ const ui = useUiStore()
36
+ const store = useTaskExpansionStore()
37
+
38
+ function rectOf(id: string): DOMRect | null {
39
+ const el = document.querySelector(`[data-block-id="${id}"]`) as HTMLElement | null
40
+ return el ? el.getBoundingClientRect() : null
41
+ }
42
+
43
+ function recompute() {
44
+ // Task cards only expand at the deep zoom bands; clear everything otherwise.
45
+ if (!lodAtLeast(ui.lod, 'steps')) {
46
+ if (store.allowed.size) store.setAllowed(new Set())
47
+ return
48
+ }
49
+ const view = container.value?.getBoundingClientRect()
50
+ if (!view) return
51
+ const cx = view.left + view.width / 2
52
+ const cy = view.top + view.height / 2
53
+
54
+ const candidates: { id: string; rect: DOMRect; dist: number }[] = []
55
+ for (const t of board.allTasks) {
56
+ // Only tasks whose run actually has steps would expand a pipeline list.
57
+ if (!execution.getByBlock(t.id)?.steps.length) continue
58
+ const rect = rectOf(t.id)
59
+ if (!rect) continue
60
+ // Visibility: the card must intersect the board viewport.
61
+ if (!intersects(rect, view)) continue
62
+ // Stable anchor: the card's top-centre. It doesn't move as the card grows
63
+ // downward, so the ordering can't oscillate as cards expand / collapse.
64
+ const ax = rect.left + rect.width / 2
65
+ const ay = rect.top
66
+ const dist = (ax - cx) ** 2 + (ay - cy) ** 2
67
+ candidates.push({ id: t.id, rect, dist })
68
+ }
69
+ candidates.sort((a, b) => a.dist - b.dist)
70
+
71
+ // Greedy by distance to centre: a candidate is granted only if its rect clears
72
+ // every rect already granted, so the centre-most card wins any overlap.
73
+ const claimed: DOMRect[] = []
74
+ const next = new Set<string>()
75
+ for (const c of candidates) {
76
+ if (claimed.some((r) => intersects(c.rect, r))) continue
77
+ next.add(c.id)
78
+ claimed.push(c.rect)
79
+ }
80
+ if (!sameSet(next, store.allowed)) store.setAllowed(next)
81
+ }
82
+
83
+ const { pause, resume } = useRafFn(recompute, { immediate: false })
84
+ onMounted(() => {
85
+ store.setDriverActive(true)
86
+ resume()
87
+ })
88
+ onBeforeUnmount(() => {
89
+ pause()
90
+ store.setDriverActive(false)
91
+ })
92
+ }
@@ -0,0 +1,155 @@
1
+ import { ref, onScopeDispose } from 'vue'
2
+ import type { WorkspaceEvent } from '~/types/domain'
3
+
4
+ /**
5
+ * Subscribes to the backend's per-workspace WebSocket event stream and keeps the
6
+ * board in sync in real time — the replacement for the old polling clock. Mount
7
+ * once (e.g. on the board page) after the workspace is ready.
8
+ *
9
+ * `execution` events patch the run + its block directly; `bootstrap` events patch
10
+ * a repo-bootstrap run + its service frame (live "bootstrapping…" progress); the
11
+ * coarse `board` event (module materialised, run cancelled) triggers a debounced
12
+ * full refresh. On every (re)connect we refresh once to reconcile anything missed
13
+ * while disconnected, so the server stays the source of truth and a dropped socket
14
+ * self-heals.
15
+ */
16
+ export function useWorkspaceStream() {
17
+ const workspace = useWorkspaceStore()
18
+ const execution = useExecutionStore()
19
+ const board = useBoardStore()
20
+ const agentRuns = useAgentRunsStore()
21
+ const notifications = useNotificationsStore()
22
+ const observability = useObservabilityStore()
23
+ const requirements = useRequirementsStore()
24
+ const consensus = useConsensusStore()
25
+ const clarity = useClarityStore()
26
+ const api = useApi()
27
+ const apiBase = useRuntimeConfig().public.apiBase
28
+
29
+ const connected = ref(false)
30
+
31
+ let socket: WebSocket | null = null
32
+ let stopped = false
33
+ let attempt = 0
34
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
35
+ let boardDebounce: ReturnType<typeof setTimeout> | null = null
36
+
37
+ // http→ws, https→wss (apiBase is an absolute origin, see nuxt.config.ts).
38
+ const wsBase = String(apiBase).replace(/^http/, 'ws')
39
+
40
+ function debouncedBoardRefresh() {
41
+ if (boardDebounce) clearTimeout(boardDebounce)
42
+ boardDebounce = setTimeout(() => void workspace.refresh(), 300)
43
+ }
44
+
45
+ function onMessage(raw: string) {
46
+ let event: WorkspaceEvent
47
+ try {
48
+ event = JSON.parse(raw) as WorkspaceEvent
49
+ } catch {
50
+ return
51
+ }
52
+ if (event.type === 'execution') {
53
+ // Full instance drives the step-level UI; agentRuns derives its coarse
54
+ // failure/retry summary from the same store, so no extra call is needed.
55
+ execution.upsert(event.instance)
56
+ if (event.block) board.upsert(event.block)
57
+ } else if (event.type === 'board') {
58
+ debouncedBoardRefresh()
59
+ } else if (event.type === 'bootstrap') {
60
+ // Patch the run's live status/subtasks and its provisional/linked frame so
61
+ // the "bootstrapping…" card updates in place (then flips to a ready service
62
+ // or a failed badge) without a full refresh.
63
+ agentRuns.upsertBootstrap(event.job)
64
+ if (event.block) board.upsert(event.block)
65
+ } else if (event.type === 'notification') {
66
+ // A PR needs a merge decision, a pipeline finished, or CI gave up — patch the
67
+ // inbox + per-block badge in place (resolved ones drop out of the inbox).
68
+ notifications.upsert(event.notification)
69
+ } else if (event.type === 'llmCall') {
70
+ // A container agent just made a model call — fold the compact summary into the
71
+ // observability store so an open "Model activity" panel updates live (and keeps
72
+ // updating even when the durable driver is evicted: the proxy emits these
73
+ // independently of the run's poll loop).
74
+ observability.appendCall(event.call)
75
+ } else if (event.type === 'requirements') {
76
+ // The async incorporate + re-review cycle changed a review's status — patch the cache
77
+ // so an open review window / inspector reflects it live ("incorporating…" → the next
78
+ // cycle / converged). The summons back, when needed, arrives as a `notification`.
79
+ requirements.upsert(event.review)
80
+ } else if (event.type === 'consensus') {
81
+ // A consensus session advanced (a round landed, the synthesis completed, or it
82
+ // failed) — patch the cache so an open Consensus Session window renders the
83
+ // multi-model process live, round by round.
84
+ consensus.upsert(event.session)
85
+ } else if (event.type === 'clarity') {
86
+ // The async incorporate + re-review cycle changed a clarity review's status — patch the
87
+ // cache so an open review window / inspector reflects it live ("incorporating…" → the
88
+ // next cycle / converged). The summons back, when needed, arrives as a `notification`.
89
+ clarity.upsert(event.review)
90
+ }
91
+ }
92
+
93
+ async function connect() {
94
+ if (stopped || !workspace.workspaceId) return
95
+ const workspaceId = workspace.workspaceId
96
+
97
+ // A browser can't set Authorization on a WS handshake, so mint a short-lived,
98
+ // workspace-scoped ticket over the authenticated REST channel and pass it as
99
+ // `?ticket=`. Empty when auth is disabled (dev) — the handshake is open then.
100
+ let ticket: string
101
+ try {
102
+ ticket = (await api.mintEventsTicket(workspaceId)).ticket
103
+ } catch {
104
+ // Couldn't mint (offline, token lapsed) — back off and retry.
105
+ scheduleReconnect()
106
+ return
107
+ }
108
+ // A workspace switch (or stop()) may have happened while awaiting the mint.
109
+ if (stopped || workspace.workspaceId !== workspaceId) return
110
+
111
+ const query = ticket ? `?ticket=${encodeURIComponent(ticket)}` : ''
112
+ socket = new WebSocket(`${wsBase}/workspaces/${workspaceId}/events${query}`)
113
+
114
+ socket.onopen = () => {
115
+ attempt = 0
116
+ connected.value = true
117
+ // Resync on (re)connect: any event missed while disconnected is reconciled.
118
+ // The snapshot carries `bootstrapJobs` + executions, so one refresh rehydrates
119
+ // agentRuns too — a missed terminal event (e.g. a container eviction that
120
+ // failed the run) can't leave a frame stuck on a stale "bootstrapping…" badge.
121
+ void workspace.refresh()
122
+ }
123
+ socket.onmessage = (e) => onMessage(typeof e.data === 'string' ? e.data : '')
124
+ socket.onclose = () => {
125
+ connected.value = false
126
+ scheduleReconnect()
127
+ }
128
+ socket.onerror = () => socket?.close()
129
+ }
130
+
131
+ function scheduleReconnect() {
132
+ if (stopped) return
133
+ socket = null
134
+ const delay = Math.min(30_000, 500 * 2 ** attempt) // 0.5s → 30s cap
135
+ attempt += 1
136
+ reconnectTimer = setTimeout(connect, delay)
137
+ }
138
+
139
+ function start() {
140
+ stopped = false
141
+ connect()
142
+ }
143
+
144
+ function stop() {
145
+ stopped = true
146
+ if (reconnectTimer) clearTimeout(reconnectTimer)
147
+ if (boardDebounce) clearTimeout(boardDebounce)
148
+ socket?.close()
149
+ socket = null
150
+ connected.value = false
151
+ }
152
+
153
+ onScopeDispose(stop)
154
+ return { start, stop, connected }
155
+ }
@@ -0,0 +1,31 @@
1
+ # Frontend architecture — state & data flow
2
+
3
+ How the SPA stays in sync with the backend. The app is a **thin client**: it holds
4
+ no business logic, calls the Worker for every mutation, and hydrates its stores
5
+ from server snapshots plus pushed events. For the high-level tour see
6
+ [`../README.md`](../README.md).
7
+
8
+ ## The three paths
9
+
10
+ ```
11
+ REST (useApi) ─────────────▶ Worker ─────────────▶ D1
12
+ ▲ │
13
+ │ mutations │ persisted transition
14
+ stores (Pinia) ◀── patch ── useWorkspaceStream ◀── WebSocket push (events hub)
15
+ ```
16
+
17
+ - **Read path** — the `workspace` store loads the full snapshot and fans it into
18
+ `board`, `pipelines`, `execution`, `spend`, etc.
19
+ - **Write path** — components call `useApi` → Worker; the response (or a pushed
20
+ event) patches the relevant store. No optimistic business logic.
21
+ - **Live path** — `useWorkspaceStream` opens one WebSocket to
22
+ `GET /workspaces/:ws/events?token=…`, patches `execution` / `agentRuns` /
23
+ `board` as events arrive, and refreshes on reconnect to reconcile anything
24
+ missed.
25
+
26
+ ## Stores
27
+
28
+ One Pinia store per feature domain: `workspace`, `accounts`, `auth`, `board`,
29
+ `ui`, `pipelines`, `agents`, `execution`, `agentRuns`, `models`, `github`,
30
+ `bootstrap`, `documents`, `tasks`, `requirements`, `scenarios`, `fragments`
31
+ (built-in catalog), `fragmentLibrary` (tenant tiers + sources), and `spend`.
@@ -0,0 +1,141 @@
1
+ <script setup lang="ts">
2
+ import BoardCanvas from '~/components/board/BoardCanvas.vue'
3
+ import SideBar from '~/components/layout/SideBar.vue'
4
+ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
5
+ import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
6
+ import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
7
+ import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
8
+ import InspectorPanel from '~/components/panels/InspectorPanel.vue'
9
+ import DecisionModal from '~/components/panels/DecisionModal.vue'
10
+ import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
11
+ import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
12
+ import ObservabilityPanel from '~/components/panels/ObservabilityPanel.vue'
13
+ import BlockFocusView from '~/components/focus/BlockFocusView.vue'
14
+ import DocumentSourceConnectModal from '~/components/documents/DocumentSourceConnectModal.vue'
15
+ import DocumentImportModal from '~/components/documents/DocumentImportModal.vue'
16
+ import SpawnPreviewModal from '~/components/documents/SpawnPreviewModal.vue'
17
+ import TaskSourceConnectModal from '~/components/tasks/TaskSourceConnectModal.vue'
18
+ import TaskImportModal from '~/components/tasks/TaskImportModal.vue'
19
+ import AddTaskModal from '~/components/board/AddTaskModal.vue'
20
+ import RecurringPipelineModal from '~/components/board/RecurringPipelineModal.vue'
21
+ import BootstrapModal from '~/components/bootstrap/BootstrapModal.vue'
22
+ import AddServiceFromRepoModal from '~/components/github/AddServiceFromRepoModal.vue'
23
+ import GitHubPanel from '~/components/github/GitHubPanel.vue'
24
+ import SlackPanel from '~/components/slack/SlackPanel.vue'
25
+ import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
26
+ import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
27
+ import CommandBar from '~/components/layout/CommandBar.vue'
28
+ import MergeThresholdsPanel from '~/components/settings/MergeThresholdsPanel.vue'
29
+ import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
30
+ import DatadogPanel from '~/components/settings/DatadogPanel.vue'
31
+ import ModelDefaultsPanel from '~/components/settings/ModelDefaultsPanel.vue'
32
+ import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
33
+ import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
34
+
35
+ const workspace = useWorkspaceStore()
36
+ const github = useGitHubStore()
37
+
38
+ // Load the board from the backend before rendering it.
39
+ onMounted(() => workspace.init())
40
+
41
+ // Probe the GitHub integration as soon as a board is active (re-probe per board —
42
+ // connections are per workspace). The result drives the onboarding gate below
43
+ // before the board mounts, so an unconnected user can't slip past it. SideBar
44
+ // re-probes once it mounts; that duplicate is harmless (probe is idempotent).
45
+ watch(
46
+ () => workspace.workspaceId,
47
+ (id) => {
48
+ if (id) void github.probe()
49
+ },
50
+ { immediate: true },
51
+ )
52
+
53
+ // Hard gate: the App is enabled on the backend but this workspace has no
54
+ // installation yet. `available === null` means the probe is still in flight.
55
+ const needsGitHubInstall = computed(() => github.available === true && !github.connected)
56
+ const githubProbePending = computed(() => github.available === null)
57
+
58
+ // Subscribe to the backend's real-time event stream and (re)connect whenever the
59
+ // active workspace changes. Runs advance durably server-side; progress arrives as
60
+ // pushed events rather than by polling.
61
+ const stream = useWorkspaceStream()
62
+ watch(
63
+ () => workspace.workspaceId,
64
+ (id) => {
65
+ stream.stop()
66
+ if (id) stream.start()
67
+ },
68
+ { immediate: true },
69
+ )
70
+ </script>
71
+
72
+ <template>
73
+ <div class="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
74
+ <!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
75
+ <GitHubPatBanner />
76
+
77
+ <!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
78
+ <div
79
+ v-if="workspace.ready && githubProbePending"
80
+ class="m-auto flex flex-col items-center gap-3 text-slate-400"
81
+ >
82
+ <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
83
+ <span class="text-sm">Loading…</span>
84
+ </div>
85
+
86
+ <!-- App enabled but not installed on this workspace: hard onboarding gate. -->
87
+ <GitHubOnboarding v-else-if="workspace.ready && needsGitHubInstall" />
88
+
89
+ <template v-else-if="workspace.ready">
90
+ <SideBar />
91
+ <main class="relative min-w-0 flex-1">
92
+ <BoardCanvas />
93
+ <BoardToolbar />
94
+ <SpendWarningBanner />
95
+ <InspectorPanel />
96
+ <BlockFocusView />
97
+ </main>
98
+
99
+ <PipelineBuilder />
100
+ <DecisionModal />
101
+ <AgentStepDetail />
102
+ <StepResultViewHost />
103
+ <ObservabilityPanel />
104
+ <DocumentSourceConnectModal />
105
+ <DocumentImportModal />
106
+ <SpawnPreviewModal />
107
+ <TaskSourceConnectModal />
108
+ <TaskImportModal />
109
+ <AddTaskModal />
110
+ <RecurringPipelineModal />
111
+ <BootstrapModal />
112
+ <AddServiceFromRepoModal />
113
+ <GitHubPanel />
114
+ <SlackPanel />
115
+ <FragmentLibraryPanel />
116
+ <CommandBar />
117
+ <MergeThresholdsPanel />
118
+ <WorkspaceSettingsPanel />
119
+ <DatadogPanel />
120
+ <ModelDefaultsPanel />
121
+ <ServiceFragmentDefaultsPanel />
122
+ <LocalModelEndpointsPanel />
123
+ </template>
124
+
125
+ <!-- Backend unreachable / bootstrap failed -->
126
+ <div v-else-if="workspace.error" class="m-auto max-w-md p-8 text-center">
127
+ <UIcon name="i-lucide-plug-zap" class="mx-auto mb-3 h-10 w-10 text-amber-400" />
128
+ <h1 class="mb-1 text-lg font-semibold">Can’t reach the backend</h1>
129
+ <p class="mb-4 text-sm text-slate-400">{{ workspace.error }}</p>
130
+ <UButton color="primary" icon="i-lucide-rotate-ccw" @click="workspace.init()">
131
+ Retry
132
+ </UButton>
133
+ </div>
134
+
135
+ <!-- Initial load -->
136
+ <div v-else class="m-auto flex flex-col items-center gap-3 text-slate-400">
137
+ <UIcon name="i-lucide-loader" class="h-8 w-8 animate-spin" />
138
+ <span class="text-sm">Loading board…</span>
139
+ </div>
140
+ </div>
141
+ </template>