@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,210 @@
1
+ <script setup lang="ts">
2
+ // Consensus session window — the dedicated, read-only surface for any step that ran the
3
+ // multi-model consensus mechanism (specialist panel / debate / ranked voting). Opened via
4
+ // the universal result-view host (routed in `ui.dispatchStepView` when a step's
5
+ // `consensus.enabled`). Visualizes the process for observability: the participants + their
6
+ // models, the round-by-round contributions (anonymized as the models saw each other),
7
+ // per-candidate votes/scores, and the synthesized result + confidence/dissent. Updates live
8
+ // as `consensus` stream events arrive.
9
+ import { computed } from 'vue'
10
+ import type { ConsensusContribution, ConsensusSession } from '~/types/consensus'
11
+
12
+ const board = useBoardStore()
13
+ const consensus = useConsensusStore()
14
+
15
+ const { open, blockId, close } = useResultView('consensus-session', {
16
+ onOpen: (id) => {
17
+ void consensus.load(id)
18
+ },
19
+ })
20
+
21
+ const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
22
+ const session = computed<ConsensusSession | null>(() =>
23
+ blockId.value ? consensus.sessionFor(blockId.value) : null,
24
+ )
25
+ const loading = computed(() => (blockId.value ? consensus.isLoading(blockId.value) : false))
26
+
27
+ const STRATEGY_LABEL: Record<string, string> = {
28
+ 'specialist-panel': 'Specialist panel',
29
+ debate: 'Debate',
30
+ 'ranked-voting': 'Ranked voting',
31
+ }
32
+ const ROUND_LABEL: Record<string, string> = {
33
+ draft: 'Independent drafts',
34
+ critique: 'Critique & revision',
35
+ score: 'Scoring',
36
+ }
37
+
38
+ const STATUS_META: Record<string, { label: string; class: string }> = {
39
+ running: { label: 'Running', class: 'bg-sky-500/15 text-sky-300' },
40
+ synthesizing: { label: 'Synthesizing', class: 'bg-indigo-500/15 text-indigo-300' },
41
+ done: { label: 'Done', class: 'bg-emerald-500/15 text-emerald-300' },
42
+ failed: { label: 'Failed', class: 'bg-rose-500/15 text-rose-300' },
43
+ }
44
+
45
+ /** Anonymous label (Expert A/B/…) for a participant, matching the backend's ordering. */
46
+ function anonLabel(participantId: string): string {
47
+ const idx = session.value?.participants.findIndex((p) => p.id === participantId) ?? -1
48
+ return `Expert ${String.fromCharCode(65 + (idx < 0 ? 0 : idx % 26))}`
49
+ }
50
+
51
+ function roleFor(participantId: string): string {
52
+ return session.value?.participants.find((p) => p.id === participantId)?.role ?? 'Participant'
53
+ }
54
+
55
+ function pct(n: number | null | undefined): string {
56
+ return n == null ? '—' : `${Math.round(n * 100)}%`
57
+ }
58
+
59
+ function topScore(c: ConsensusContribution): { label: string; value: number } | null {
60
+ if (!c.scores?.length) return null
61
+ const best = [...c.scores].sort((a, b) => b.value - a.value)[0]!
62
+ return { label: best.dimension, value: best.value }
63
+ }
64
+ </script>
65
+
66
+ <template>
67
+ <Teleport to="body">
68
+ <div
69
+ v-if="open"
70
+ class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
71
+ @click.self="close"
72
+ >
73
+ <div
74
+ class="flex h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
75
+ >
76
+ <!-- header -->
77
+ <header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
78
+ <div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-amber-500/15">
79
+ <UIcon name="i-lucide-users-round" class="h-5 w-5 text-amber-300" />
80
+ </div>
81
+ <div class="min-w-0 flex-1">
82
+ <h2 class="truncate text-sm font-semibold text-slate-100">
83
+ Consensus ·
84
+ {{ session ? (STRATEGY_LABEL[session.strategy] ?? session.strategy) : '' }}
85
+ <span v-if="block" class="font-normal text-slate-400">— {{ block.title }}</span>
86
+ </h2>
87
+ <p v-if="session" class="text-xs text-slate-500">
88
+ {{ session.agentKind }} · {{ session.participants.length }} participants
89
+ </p>
90
+ </div>
91
+ <span
92
+ v-if="session"
93
+ class="rounded-full px-2.5 py-1 text-xs font-medium"
94
+ :class="STATUS_META[session.status]?.class ?? 'bg-slate-700 text-slate-300'"
95
+ >
96
+ {{ STATUS_META[session.status]?.label ?? session.status }}
97
+ </span>
98
+ <button
99
+ class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
100
+ @click="close"
101
+ >
102
+ <UIcon name="i-lucide-x" class="h-5 w-5" />
103
+ </button>
104
+ </header>
105
+
106
+ <div class="flex-1 overflow-y-auto px-6 py-5">
107
+ <div v-if="loading && !session" class="py-16 text-center text-sm text-slate-500">
108
+ Loading consensus session…
109
+ </div>
110
+ <div v-else-if="!session" class="py-16 text-center text-sm text-slate-500">
111
+ No consensus session has run for this step yet.
112
+ </div>
113
+ <template v-else>
114
+ <!-- failure -->
115
+ <div
116
+ v-if="session.status === 'failed'"
117
+ class="mb-5 rounded-lg border border-rose-800/60 bg-rose-950/40 px-4 py-3 text-sm text-rose-200"
118
+ >
119
+ Consensus failed: {{ session.error ?? 'unknown error' }}
120
+ </div>
121
+
122
+ <!-- synthesized result -->
123
+ <section v-if="session.synthesis" class="mb-6">
124
+ <div class="mb-2 flex items-center gap-2">
125
+ <h3 class="text-xs font-semibold uppercase tracking-wide text-slate-400">
126
+ Synthesized result
127
+ </h3>
128
+ <span
129
+ v-if="session.confidence != null"
130
+ class="rounded bg-emerald-500/15 px-1.5 py-0.5 text-xs text-emerald-300"
131
+ >confidence {{ pct(session.confidence) }}</span
132
+ >
133
+ </div>
134
+ <pre
135
+ class="whitespace-pre-wrap rounded-lg border border-slate-800 bg-slate-950/60 px-4 py-3 text-sm text-slate-200"
136
+ >{{ session.synthesis }}</pre
137
+ >
138
+ <ul v-if="session.dissent?.length" class="mt-2 space-y-1">
139
+ <li
140
+ v-for="(d, i) in session.dissent"
141
+ :key="i"
142
+ class="flex items-start gap-2 text-xs text-amber-300/90"
143
+ >
144
+ <UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-3.5 w-3.5 shrink-0" />
145
+ <span>{{ d }}</span>
146
+ </li>
147
+ </ul>
148
+ </section>
149
+
150
+ <!-- participants -->
151
+ <section class="mb-6">
152
+ <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
153
+ Panel
154
+ </h3>
155
+ <div class="flex flex-wrap gap-2">
156
+ <div
157
+ v-for="(p, i) in session.participants"
158
+ :key="p.id"
159
+ class="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-1.5 text-xs"
160
+ >
161
+ <span class="font-medium text-slate-200"
162
+ >Expert {{ String.fromCharCode(65 + i) }}</span
163
+ >
164
+ <span class="text-slate-400"> · {{ p.role }}</span>
165
+ <span v-if="p.modelId" class="ml-1 text-slate-500">({{ p.modelId }})</span>
166
+ </div>
167
+ </div>
168
+ </section>
169
+
170
+ <!-- rounds -->
171
+ <section v-for="round in session.rounds" :key="round.index" class="mb-5">
172
+ <h3 class="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
173
+ Round {{ round.index + 1 }} ·
174
+ {{ round.kind ? (ROUND_LABEL[round.kind] ?? round.kind) : 'Contributions' }}
175
+ </h3>
176
+ <div class="space-y-3">
177
+ <div
178
+ v-for="c in round.contributions"
179
+ :key="c.participantId"
180
+ class="rounded-lg border border-slate-800 bg-slate-950/40 px-4 py-3"
181
+ >
182
+ <div class="mb-1 flex items-center gap-2">
183
+ <span class="text-xs font-semibold text-slate-200">{{
184
+ anonLabel(c.participantId)
185
+ }}</span>
186
+ <span class="text-xs text-slate-500">{{ roleFor(c.participantId) }}</span>
187
+ <span
188
+ v-if="topScore(c)"
189
+ class="ml-auto rounded bg-slate-800 px-1.5 py-0.5 text-xs text-slate-300"
190
+ >top {{ topScore(c)!.label }} {{ pct(topScore(c)!.value) }}</span
191
+ >
192
+ </div>
193
+ <pre class="whitespace-pre-wrap text-sm text-slate-300">{{ c.text }}</pre>
194
+ <div v-if="c.scores?.length" class="mt-2 flex flex-wrap gap-1.5">
195
+ <span
196
+ v-for="s in c.scores"
197
+ :key="s.dimension"
198
+ class="rounded bg-slate-800/80 px-1.5 py-0.5 text-xs text-slate-400"
199
+ >{{ s.dimension }}: {{ pct(s.value) }}</span
200
+ >
201
+ </div>
202
+ </div>
203
+ </div>
204
+ </section>
205
+ </template>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ </Teleport>
210
+ </template>
@@ -0,0 +1,161 @@
1
+ <script setup lang="ts">
2
+ import type { DocumentSourceKind } from '~/types/domain'
3
+
4
+ // Import pages from a connected document source and pick one to expand into
5
+ // board structure. A source selector lets the user choose which connected source
6
+ // to import from (Confluence, Notion, …). Carries an optional target frame from
7
+ // the inspector so "Preview & spawn" lands the structure inside that frame.
8
+ const ui = useUiStore()
9
+ const documents = useDocumentsStore()
10
+ const board = useBoardStore()
11
+ const toast = useToast()
12
+
13
+ const open = computed({
14
+ get: () => ui.documentImport !== null,
15
+ set: (v: boolean) => {
16
+ if (!v) ui.closeDocumentImport()
17
+ },
18
+ })
19
+
20
+ const targetFrameId = computed(() => ui.documentImport?.targetFrameId ?? null)
21
+ const targetFrameTitle = computed(() =>
22
+ targetFrameId.value ? board.getBlock(targetFrameId.value)?.title : null,
23
+ )
24
+
25
+ /** Which connected source we're importing from (defaults to the first). */
26
+ const source = ref<DocumentSourceKind | undefined>(undefined)
27
+ const ref_ = ref('')
28
+ const importing = ref(false)
29
+
30
+ const sourceItems = computed(() =>
31
+ documents.connectedSources.map((s) => ({ label: s.label, value: s.source })),
32
+ )
33
+ const descriptor = computed(() =>
34
+ source.value ? documents.descriptorFor(source.value) : undefined,
35
+ )
36
+
37
+ /** Documents imported from the currently selected source. */
38
+ const sourceDocs = computed(() =>
39
+ source.value ? documents.documents.filter((d) => d.source === source.value) : [],
40
+ )
41
+
42
+ watch(open, (isOpen) => {
43
+ if (isOpen) {
44
+ ref_.value = ''
45
+ source.value = ui.documentImport?.source ?? documents.connectedSources[0]?.source ?? undefined
46
+ documents.loadDocuments().catch(() => {})
47
+ }
48
+ })
49
+
50
+ async function doImport() {
51
+ const value = ref_.value.trim()
52
+ if (!value || !source.value) return
53
+ importing.value = true
54
+ try {
55
+ const doc = await documents.importDocument(source.value, value)
56
+ ref_.value = ''
57
+ toast.add({ title: `Imported "${doc.title}"`, icon: 'i-lucide-file-down' })
58
+ } catch (e) {
59
+ toast.add({
60
+ title: 'Import failed',
61
+ description: e instanceof Error ? e.message : String(e),
62
+ icon: 'i-lucide-triangle-alert',
63
+ color: 'error',
64
+ })
65
+ } finally {
66
+ importing.value = false
67
+ }
68
+ }
69
+
70
+ function preview(externalId: string) {
71
+ if (source.value) ui.openSpawnPreview(source.value, externalId, targetFrameId.value)
72
+ }
73
+ </script>
74
+
75
+ <template>
76
+ <UModal v-model:open="open" title="Import from a document source">
77
+ <template #body>
78
+ <div v-if="!documents.anyConnected" class="space-y-3 text-center">
79
+ <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
80
+ <p class="text-sm text-slate-400">Connect a document source first.</p>
81
+ <div class="flex justify-center gap-2">
82
+ <UButton
83
+ v-for="s in documents.sources"
84
+ :key="s.source"
85
+ color="primary"
86
+ variant="soft"
87
+ :icon="s.icon"
88
+ @click="ui.openDocumentConnect(s.source)"
89
+ >
90
+ Connect {{ s.label }}
91
+ </UButton>
92
+ </div>
93
+ </div>
94
+
95
+ <div v-else class="space-y-4">
96
+ <p v-if="targetFrameTitle" class="text-xs text-slate-400">
97
+ Spawning into <span class="font-medium text-slate-200">{{ targetFrameTitle }}</span>
98
+ </p>
99
+
100
+ <UFormField v-if="sourceItems.length > 1" label="Source">
101
+ <USelect v-model="source" :items="sourceItems" class="w-full" />
102
+ </UFormField>
103
+
104
+ <div class="flex items-end gap-2">
105
+ <UFormField :label="descriptor?.refLabel ?? 'Page URL or ID'" class="flex-1">
106
+ <UInput
107
+ v-model="ref_"
108
+ :placeholder="descriptor?.refPlaceholder"
109
+ class="w-full"
110
+ @keydown.enter="doImport"
111
+ />
112
+ </UFormField>
113
+ <UButton
114
+ color="primary"
115
+ icon="i-lucide-file-down"
116
+ :loading="importing"
117
+ :disabled="!ref_.trim()"
118
+ @click="doImport"
119
+ >
120
+ Import
121
+ </UButton>
122
+ </div>
123
+
124
+ <div v-if="sourceDocs.length" class="space-y-2">
125
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
126
+ Imported documents
127
+ </h3>
128
+ <div
129
+ v-for="doc in sourceDocs"
130
+ :key="`${doc.source}:${doc.externalId}`"
131
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
132
+ >
133
+ <div class="flex items-start justify-between gap-2">
134
+ <div class="min-w-0">
135
+ <a
136
+ :href="doc.url"
137
+ target="_blank"
138
+ rel="noopener"
139
+ class="truncate text-sm font-medium text-white hover:underline"
140
+ >
141
+ {{ doc.title }}
142
+ </a>
143
+ <p class="mt-0.5 line-clamp-2 text-xs text-slate-500">{{ doc.excerpt }}</p>
144
+ </div>
145
+ <UButton
146
+ color="primary"
147
+ variant="soft"
148
+ size="xs"
149
+ icon="i-lucide-wand-sparkles"
150
+ @click="preview(doc.externalId)"
151
+ >
152
+ Preview &amp; spawn
153
+ </UButton>
154
+ </div>
155
+ </div>
156
+ </div>
157
+ <p v-else class="text-center text-xs text-slate-500">No documents imported yet.</p>
158
+ </div>
159
+ </template>
160
+ </UModal>
161
+ </template>
@@ -0,0 +1,127 @@
1
+ <script setup lang="ts">
2
+ // Connect (or disconnect) the workspace to a document source. The form is
3
+ // rendered generically from the source's descriptor (credential fields), so the
4
+ // same modal serves Confluence, Notion and any future source. Secret credentials
5
+ // are write-only — the backend never returns them, so on reload we show
6
+ // "Connected" with empty fields.
7
+ const ui = useUiStore()
8
+ const documents = useDocumentsStore()
9
+ const toast = useToast()
10
+
11
+ const source = computed(() => ui.documentConnect?.source ?? null)
12
+ const descriptor = computed(() =>
13
+ source.value ? documents.descriptorFor(source.value) : undefined,
14
+ )
15
+ const connection = computed(() =>
16
+ source.value ? documents.connectionFor(source.value) : undefined,
17
+ )
18
+ const connected = computed(() => connection.value !== undefined)
19
+
20
+ const open = computed({
21
+ get: () => ui.documentConnect !== null,
22
+ set: (v: boolean) => {
23
+ if (!v) ui.closeDocumentConnect()
24
+ },
25
+ })
26
+
27
+ /** One value per credential field, reset whenever the modal (re)opens. */
28
+ const values = ref<Record<string, string>>({})
29
+ const saving = ref(false)
30
+
31
+ watch(open, (isOpen) => {
32
+ if (isOpen) values.value = {}
33
+ })
34
+
35
+ const canSubmit = computed(() => {
36
+ const fields = descriptor.value?.credentialFields ?? []
37
+ return fields.length > 0 && fields.every((f) => (values.value[f.key] ?? '').trim())
38
+ })
39
+
40
+ async function submit() {
41
+ if (!canSubmit.value || !source.value) return
42
+ const credentials: Record<string, string> = {}
43
+ for (const f of descriptor.value!.credentialFields) {
44
+ credentials[f.key] = values.value[f.key]!.trim()
45
+ }
46
+ saving.value = true
47
+ try {
48
+ await documents.connect(source.value, credentials)
49
+ toast.add({
50
+ title: `${descriptor.value!.label} connected`,
51
+ icon: 'i-lucide-check',
52
+ color: 'success',
53
+ })
54
+ ui.closeDocumentConnect()
55
+ } catch (e) {
56
+ toast.add({
57
+ title: 'Could not connect',
58
+ description: e instanceof Error ? e.message : String(e),
59
+ icon: 'i-lucide-triangle-alert',
60
+ color: 'error',
61
+ })
62
+ } finally {
63
+ saving.value = false
64
+ }
65
+ }
66
+
67
+ async function disconnect() {
68
+ if (!source.value) return
69
+ await documents.disconnect(source.value)
70
+ toast.add({
71
+ title: `${descriptor.value?.label ?? 'Source'} disconnected`,
72
+ icon: 'i-lucide-unplug',
73
+ })
74
+ ui.closeDocumentConnect()
75
+ }
76
+ </script>
77
+
78
+ <template>
79
+ <UModal v-model:open="open" :title="descriptor?.label ?? 'Connect source'">
80
+ <template #body>
81
+ <div v-if="descriptor" class="space-y-4">
82
+ <p class="text-sm text-slate-400">
83
+ Connect {{ descriptor.label }} to import requirements, RFCs and PRDs, then spawn board
84
+ structure or attach them to tasks as agent context.
85
+ </p>
86
+
87
+ <div class="space-y-3">
88
+ <UFormField
89
+ v-for="field in descriptor.credentialFields"
90
+ :key="field.key"
91
+ :label="field.label"
92
+ :help="field.help"
93
+ >
94
+ <UInput
95
+ v-model="values[field.key]"
96
+ :type="field.secret ? 'password' : 'text'"
97
+ :placeholder="field.placeholder"
98
+ class="w-full"
99
+ />
100
+ </UFormField>
101
+ </div>
102
+
103
+ <div class="flex items-center justify-between gap-2 pt-1">
104
+ <UButton
105
+ v-if="connected"
106
+ color="error"
107
+ variant="ghost"
108
+ icon="i-lucide-unplug"
109
+ @click="disconnect"
110
+ >
111
+ Disconnect
112
+ </UButton>
113
+ <div v-else />
114
+ <UButton
115
+ color="primary"
116
+ icon="i-lucide-plug"
117
+ :loading="saving"
118
+ :disabled="!canSubmit"
119
+ @click="submit"
120
+ >
121
+ {{ connected ? 'Update connection' : 'Connect' }}
122
+ </UButton>
123
+ </div>
124
+ </div>
125
+ </template>
126
+ </UModal>
127
+ </template>
@@ -0,0 +1,161 @@
1
+ <script setup lang="ts">
2
+ import type { DocumentBoardPlan } from '~/types/domain'
3
+
4
+ // Preview the structure an imported document expands into, then spawn it. The
5
+ // plan is fetched fresh on open; a badge makes clear whether an LLM or the
6
+ // deterministic heading parser produced it.
7
+ const ui = useUiStore()
8
+ const documents = useDocumentsStore()
9
+ const board = useBoardStore()
10
+ const toast = useToast()
11
+
12
+ const open = computed({
13
+ get: () => ui.spawnPreview !== null,
14
+ set: (v: boolean) => {
15
+ if (!v) ui.closeSpawnPreview()
16
+ },
17
+ })
18
+
19
+ const targetFrameId = computed(() => ui.spawnPreview?.targetFrameId ?? null)
20
+ const targetFrameTitle = computed(() =>
21
+ targetFrameId.value ? board.getBlock(targetFrameId.value)?.title : null,
22
+ )
23
+
24
+ const plan = ref<DocumentBoardPlan | null>(null)
25
+ const loadingPlan = ref(false)
26
+ const spawning = ref(false)
27
+
28
+ watch(
29
+ () => ui.spawnPreview?.externalId,
30
+ async (externalId) => {
31
+ plan.value = null
32
+ const preview = ui.spawnPreview
33
+ if (!externalId || !preview) return
34
+ loadingPlan.value = true
35
+ try {
36
+ plan.value = await documents.plan(preview.source, externalId)
37
+ } catch (e) {
38
+ toast.add({
39
+ title: 'Could not build a plan',
40
+ description: e instanceof Error ? e.message : String(e),
41
+ icon: 'i-lucide-triangle-alert',
42
+ color: 'error',
43
+ })
44
+ } finally {
45
+ loadingPlan.value = false
46
+ }
47
+ },
48
+ { immediate: true },
49
+ )
50
+
51
+ async function spawn() {
52
+ const preview = ui.spawnPreview
53
+ if (!preview) return
54
+ spawning.value = true
55
+ try {
56
+ const result = await documents.spawn(
57
+ preview.source,
58
+ preview.externalId,
59
+ targetFrameId.value ?? undefined,
60
+ )
61
+ toast.add({
62
+ title: 'Structure spawned',
63
+ description: `${result.frames} frames · ${result.modules} modules · ${result.tasks} tasks`,
64
+ icon: 'i-lucide-check',
65
+ color: 'success',
66
+ })
67
+ ui.closeSpawnPreview()
68
+ ui.closeDocumentImport()
69
+ } catch (e) {
70
+ toast.add({
71
+ title: 'Spawn failed',
72
+ description: e instanceof Error ? e.message : String(e),
73
+ icon: 'i-lucide-triangle-alert',
74
+ color: 'error',
75
+ })
76
+ } finally {
77
+ spawning.value = false
78
+ }
79
+ }
80
+ </script>
81
+
82
+ <template>
83
+ <UModal v-model:open="open" title="Preview structure">
84
+ <template #body>
85
+ <div class="space-y-4">
86
+ <div v-if="plan" class="flex items-center justify-between gap-2">
87
+ <UBadge
88
+ :color="plan.planner === 'llm' ? 'primary' : 'neutral'"
89
+ variant="subtle"
90
+ size="sm"
91
+ >
92
+ {{ plan.planner === 'llm' ? 'AI-generated' : 'From headings' }}
93
+ </UBadge>
94
+ <span v-if="targetFrameTitle" class="text-xs text-slate-400">
95
+ into <span class="font-medium text-slate-200">{{ targetFrameTitle }}</span>
96
+ </span>
97
+ <span v-else class="text-xs text-slate-400">as new top-level frames</span>
98
+ </div>
99
+
100
+ <div v-if="loadingPlan" class="flex items-center gap-2 text-sm text-slate-400">
101
+ <UIcon name="i-lucide-loader" class="h-4 w-4 animate-spin" /> Building plan…
102
+ </div>
103
+
104
+ <div v-else-if="plan" class="max-h-80 space-y-3 overflow-y-auto pr-1">
105
+ <div
106
+ v-for="(frame, fi) in plan.frames"
107
+ :key="fi"
108
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
109
+ >
110
+ <div class="flex items-center gap-2">
111
+ <UIcon name="i-lucide-box" class="h-4 w-4 text-indigo-400" />
112
+ <span class="text-sm font-semibold text-white">{{ frame.title }}</span>
113
+ <UBadge variant="subtle" size="sm" color="neutral">{{ frame.type }}</UBadge>
114
+ </div>
115
+
116
+ <ul v-if="frame.tasks.length" class="mt-2 space-y-1 pl-6">
117
+ <li
118
+ v-for="(task, ti) in frame.tasks"
119
+ :key="`t-${ti}`"
120
+ class="flex items-center gap-1.5 text-xs text-slate-300"
121
+ >
122
+ <UIcon name="i-lucide-square-check-big" class="h-3 w-3 text-slate-500" />
123
+ {{ task.title }}
124
+ </li>
125
+ </ul>
126
+
127
+ <div v-for="(mod, mi) in frame.modules" :key="`m-${mi}`" class="mt-2 pl-4">
128
+ <div class="flex items-center gap-1.5 text-xs font-medium text-slate-200">
129
+ <UIcon name="i-lucide-folder" class="h-3.5 w-3.5 text-amber-400" />
130
+ {{ mod.name }}
131
+ </div>
132
+ <ul class="mt-1 space-y-1 pl-5">
133
+ <li
134
+ v-for="(task, ti) in mod.tasks"
135
+ :key="`mt-${ti}`"
136
+ class="flex items-center gap-1.5 text-xs text-slate-300"
137
+ >
138
+ <UIcon name="i-lucide-square-check-big" class="h-3 w-3 text-slate-500" />
139
+ {{ task.title }}
140
+ </li>
141
+ </ul>
142
+ </div>
143
+ </div>
144
+ </div>
145
+
146
+ <div class="flex justify-end gap-2 pt-1">
147
+ <UButton color="neutral" variant="ghost" @click="ui.closeSpawnPreview()">Cancel</UButton>
148
+ <UButton
149
+ color="primary"
150
+ icon="i-lucide-wand-sparkles"
151
+ :loading="spawning"
152
+ :disabled="!plan || loadingPlan"
153
+ @click="spawn"
154
+ >
155
+ Spawn onto board
156
+ </UButton>
157
+ </div>
158
+ </div>
159
+ </template>
160
+ </UModal>
161
+ </template>