@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,183 @@
1
+ <script setup lang="ts">
2
+ import { VueFlow, useVueFlow, type NodeMouseEvent } from '@vue-flow/core'
3
+ import { Background } from '@vue-flow/background'
4
+ import { Controls } from '@vue-flow/controls'
5
+ import { MiniMap } from '@vue-flow/minimap'
6
+ import BlockNode from './nodes/BlockNode.vue'
7
+ import TaskDependencyEdges from './TaskDependencyEdges.vue'
8
+ import { STATUS_META } from '~/utils/catalog'
9
+ import { readDndPayload, blockIdFromEvent } from '~/utils/dnd'
10
+ import { BOARD_FLOW_ID } from '~/composables/useBoardFlow'
11
+ import { useTaskExpansion } from '~/composables/useTaskExpansion'
12
+
13
+ const board = useBoardStore()
14
+ const pipelines = usePipelinesStore()
15
+ const execution = useExecutionStore()
16
+ const ui = useUiStore()
17
+ const github = useGitHubStore()
18
+ const toast = useToast()
19
+
20
+ const { onNodeDragStop, onViewportChange, screenToFlowCoordinate } = useVueFlow(BOARD_FLOW_ID)
21
+
22
+ // Gate which task cards expand their pipeline list on deep zoom: only on-screen
23
+ // cards, and only the centre-most of any that would overlap (see useTaskExpansion).
24
+ const boardEl = ref<HTMLElement | null>(null)
25
+ useTaskExpansion(boardEl)
26
+
27
+ // Only frames are board nodes. Dependencies live on tasks (rendered inside the
28
+ // frames), so there are no frame-to-frame edges on the canvas.
29
+ //
30
+ // Vue Flow tags every *draggable* node with the `nopan` class, which makes the
31
+ // pane refuse to pan while the pointer is over it. An expanded frame fills much
32
+ // of the viewport, so leaving it draggable turns the whole canvas into a dead
33
+ // zone once tasks appear. We therefore make expanded frames non-draggable (the
34
+ // pane pans straight through them) and move them via their header handle
35
+ // instead — collapsed chips stay node-draggable since they're small.
36
+ function frameExpanded(id: string) {
37
+ return ui.isFrameExpanded(id) && ui.lod !== 'far'
38
+ }
39
+
40
+ const nodes = computed(() =>
41
+ board.frames.map((b) => ({
42
+ id: b.id,
43
+ type: 'block',
44
+ position: b.position,
45
+ draggable: !frameExpanded(b.id),
46
+ data: {},
47
+ })),
48
+ )
49
+
50
+ onNodeDragStop(({ node }) => {
51
+ board.moveBlock(node.id, { x: node.position.x, y: node.position.y })
52
+ })
53
+
54
+ onViewportChange((vp) => {
55
+ ui.zoom = vp.zoom
56
+ })
57
+
58
+ function onNodeClick({ node }: NodeMouseEvent) {
59
+ ui.select(node.id)
60
+ }
61
+
62
+ function onNodeDoubleClick({ node }: NodeMouseEvent) {
63
+ // Frames are containers: double-click expands to reveal their tasks.
64
+ ui.toggleFrame(node.id)
65
+ }
66
+
67
+ function onPaneClick() {
68
+ ui.select(null)
69
+ }
70
+
71
+ function minimapColor(node: { id: string }) {
72
+ const b = board.getBlock(node.id)
73
+ return b ? STATUS_META[board.frameStatus(b.id)].color : '#475569'
74
+ }
75
+
76
+ // ---- palette drag & drop onto the canvas ----------------------------------
77
+ function onDragOver(event: DragEvent) {
78
+ event.preventDefault()
79
+ if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
80
+ }
81
+
82
+ async function onDrop(event: DragEvent) {
83
+ event.preventDefault()
84
+ const payload = readDndPayload(event)
85
+ if (!payload) return
86
+
87
+ if (payload.kind === 'block') {
88
+ const position = screenToFlowCoordinate({ x: event.clientX, y: event.clientY })
89
+ try {
90
+ const block = await board.addBlock(payload.blockType, position)
91
+ ui.select(block.id)
92
+ } catch {
93
+ toast.add({
94
+ title: 'Could not add block',
95
+ description: 'The backend rejected the request.',
96
+ color: 'error',
97
+ })
98
+ }
99
+ return
100
+ }
101
+
102
+ if (payload.kind === 'pipeline') {
103
+ // Pipelines run against tasks, not frames. The nearest [data-block-id] under
104
+ // the cursor is the task card when dropped inside an expanded frame.
105
+ const blockId = blockIdFromEvent(event)
106
+ const target = blockId ? board.getBlock(blockId) : undefined
107
+ const pipeline = pipelines.getPipeline(payload.pipelineId)
108
+ if (!target || !pipeline) return
109
+ if (target.level !== 'task') {
110
+ toast.add({
111
+ title: 'Drop onto a task',
112
+ description: 'Pipelines run against tasks, not services.',
113
+ })
114
+ return
115
+ }
116
+ if (!board.isRunnable(target.id)) {
117
+ toast.add({ title: 'Task is blocked', description: 'Its dependencies haven’t merged yet.' })
118
+ return
119
+ }
120
+ execution.start(target.id, pipeline)
121
+ ui.select(target.id)
122
+ }
123
+ }
124
+ </script>
125
+
126
+ <template>
127
+ <div ref="boardEl" class="relative h-full w-full" @drop="onDrop" @dragover="onDragOver">
128
+ <VueFlow
129
+ :id="BOARD_FLOW_ID"
130
+ :nodes="nodes"
131
+ :min-zoom="0.2"
132
+ :max-zoom="3"
133
+ :default-viewport="{ x: 40, y: 20, zoom: 0.85 }"
134
+ :pan-on-drag="[0, 2]"
135
+ fit-view-on-init
136
+ @node-click="onNodeClick"
137
+ @node-double-click="onNodeDoubleClick"
138
+ @pane-click="onPaneClick"
139
+ @contextmenu.prevent
140
+ >
141
+ <Background pattern-color="#1e293b" :gap="22" :size="1.4" />
142
+ <MiniMap pannable zoomable :node-color="minimapColor" class="!bg-slate-900/80" />
143
+ <Controls position="bottom-left" />
144
+
145
+ <template #node-block="props">
146
+ <BlockNode :id="props.id" />
147
+ </template>
148
+ </VueFlow>
149
+
150
+ <!-- An empty board reads as broken; invite the user to add a service. The
151
+ overlay lets pointer events through (so the pane still pans) except on
152
+ the buttons themselves. -->
153
+ <div
154
+ v-if="board.frames.length === 0"
155
+ class="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-4 text-center"
156
+ >
157
+ <UIcon name="i-lucide-layout-dashboard" class="h-10 w-10 text-slate-600" />
158
+ <div>
159
+ <h2 class="text-base font-semibold text-slate-300">Your board is empty</h2>
160
+ <p class="mt-1 max-w-sm text-sm text-slate-500">
161
+ Add a service to get started: bootstrap a fresh repo or pull in one you already have.
162
+ </p>
163
+ </div>
164
+ <div class="pointer-events-auto flex flex-wrap items-center justify-center gap-2">
165
+ <UButton color="primary" icon="i-lucide-git-branch-plus" @click="ui.openBootstrap()">
166
+ Bootstrap repo
167
+ </UButton>
168
+ <UButton
169
+ v-if="github.available"
170
+ color="primary"
171
+ variant="soft"
172
+ icon="i-lucide-folder-git-2"
173
+ @click="ui.openAddService()"
174
+ >
175
+ Add from existing repo
176
+ </UButton>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- task dependency arrows, overlaid in screen space -->
181
+ <TaskDependencyEdges />
182
+ </div>
183
+ </template>
@@ -0,0 +1,367 @@
1
+ <script setup lang="ts">
2
+ // A self-contained picker for attaching external context (imported docs +
3
+ // tracker issues) to a task. It surfaces the existing integrations — Confluence /
4
+ // Notion / GitHub repo docs (documents) and Jira / GitHub issues (tasks) — behind
5
+ // one control: pick a source, then either search its catalogue by title/content
6
+ // (when the source is searchable) or paste a page/issue URL or id, and pick from
7
+ // what's already been imported. Chosen items are collected into a v-model list of
8
+ // `PendingContext`; the parent links them once the block exists (see
9
+ // useContextLinking). Connection/availability gating mirrors the sidebar: a
10
+ // source that isn't connected shows a connect affordance instead of an input.
11
+ import type { DropdownMenuItem } from '@nuxt/ui'
12
+ import type {
13
+ DocumentSearchResult,
14
+ DocumentSourceKind,
15
+ TaskSearchResult,
16
+ TaskSourceKind,
17
+ } from '~/types/domain'
18
+
19
+ const model = defineModel<PendingContext[]>({ required: true })
20
+
21
+ const documents = useDocumentsStore()
22
+ const tasks = useTasksStore()
23
+ const ui = useUiStore()
24
+ const toast = useToast()
25
+
26
+ interface SourceOption {
27
+ kind: 'document' | 'task'
28
+ source: DocumentSourceKind | TaskSourceKind
29
+ label: string
30
+ icon: string
31
+ searchable: boolean
32
+ connected: boolean
33
+ refLabel: string
34
+ refPlaceholder: string
35
+ }
36
+
37
+ // Every configured source across both integrations, tagged by kind. Documents
38
+ // first, then trackers — the order the sidebar uses.
39
+ const sources = computed<SourceOption[]>(() => {
40
+ const docs: SourceOption[] = documents.available
41
+ ? documents.sources.map((s) => ({
42
+ kind: 'document',
43
+ source: s.source,
44
+ label: s.label,
45
+ icon: s.icon,
46
+ searchable: s.searchable ?? false,
47
+ connected: documents.isConnected(s.source),
48
+ refLabel: s.refLabel,
49
+ refPlaceholder: s.refPlaceholder,
50
+ }))
51
+ : []
52
+ const issues: SourceOption[] = tasks.available
53
+ ? tasks.sources.map((s) => ({
54
+ kind: 'task',
55
+ source: s.source,
56
+ label: s.label,
57
+ icon: s.icon,
58
+ searchable: s.searchable ?? false,
59
+ connected: tasks.isConnected(s.source),
60
+ refLabel: s.refLabel,
61
+ refPlaceholder: s.refPlaceholder,
62
+ }))
63
+ : []
64
+ return [...docs, ...issues]
65
+ })
66
+
67
+ const selectedKey = ref('')
68
+ const selected = computed(() =>
69
+ sources.value.find((s) => `${s.kind}:${s.source}` === selectedKey.value),
70
+ )
71
+
72
+ // Default the selection to the first connected source (else the first source),
73
+ // once the source list resolves.
74
+ watch(
75
+ sources,
76
+ (list) => {
77
+ if (selected.value || list.length === 0) return
78
+ selectedKey.value = `${(list.find((s) => s.connected) ?? list[0]!).kind}:${(list.find((s) => s.connected) ?? list[0]!).source}`
79
+ },
80
+ { immediate: true },
81
+ )
82
+
83
+ const sourceMenu = computed<DropdownMenuItem[][]>(() => [
84
+ sources.value.map((s) => ({
85
+ label: s.connected ? s.label : `${s.label} (not connected)`,
86
+ icon: s.icon,
87
+ onSelect: () => {
88
+ selectedKey.value = `${s.kind}:${s.source}`
89
+ query.value = ''
90
+ results.value = []
91
+ },
92
+ })),
93
+ ])
94
+
95
+ // The "set up a new integration" menu: every configured source, so the user can
96
+ // connect (or reconnect) one without leaving the add-task popup. Unconnected
97
+ // sources come first — those are the ones you'd typically be setting up here.
98
+ const connectMenu = computed<DropdownMenuItem[][]>(() => [
99
+ [...sources.value]
100
+ .sort((a, b) => Number(a.connected) - Number(b.connected))
101
+ .map((s) => ({
102
+ label: s.connected ? `${s.label} (reconnect)` : `Connect ${s.label}`,
103
+ icon: s.icon,
104
+ onSelect: () => connect(s),
105
+ })),
106
+ ])
107
+
108
+ // ---- search / import-by-ref ----------------------------------------------
109
+
110
+ const query = ref('')
111
+ const results = ref<(DocumentSearchResult | TaskSearchResult)[]>([])
112
+ const searching = ref(false)
113
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
114
+
115
+ watch([query, selectedKey], () => {
116
+ results.value = []
117
+ if (searchTimer) clearTimeout(searchTimer)
118
+ const src = selected.value
119
+ const q = query.value.trim()
120
+ if (!src || !src.searchable || !src.connected || q.length < 2) return
121
+ searchTimer = setTimeout(() => void runSearch(src, q), 300)
122
+ })
123
+
124
+ async function runSearch(src: SourceOption, q: string) {
125
+ searching.value = true
126
+ try {
127
+ results.value =
128
+ src.kind === 'document'
129
+ ? await documents.search(src.source as DocumentSourceKind, q)
130
+ : await tasks.search(src.source as TaskSourceKind, q)
131
+ } catch {
132
+ // A search failure (e.g. the source can't search, or a transient API error)
133
+ // just yields no results — paste-a-URL still works.
134
+ results.value = []
135
+ } finally {
136
+ searching.value = false
137
+ }
138
+ }
139
+
140
+ const selectedKeys = computed(() => new Set(model.value.map(contextKey)))
141
+
142
+ function toggle(item: PendingContext) {
143
+ const key = contextKey(item)
144
+ if (selectedKeys.value.has(key)) {
145
+ model.value = model.value.filter((c) => contextKey(c) !== key)
146
+ } else {
147
+ model.value = [...model.value, item]
148
+ }
149
+ }
150
+
151
+ /** Attach the raw input as a page/issue ref (URL or id) — imported on commit. */
152
+ function addByRef() {
153
+ const src = selected.value
154
+ const ref = query.value.trim()
155
+ if (!src || !ref) return
156
+ toggle({
157
+ kind: src.kind,
158
+ source: src.source,
159
+ externalId: ref,
160
+ title: ref,
161
+ subtitle: `${src.label} · imports on add`,
162
+ icon: src.icon,
163
+ needsImport: true,
164
+ })
165
+ query.value = ''
166
+ results.value = []
167
+ }
168
+
169
+ function pickResult(src: SourceOption, r: DocumentSearchResult | TaskSearchResult) {
170
+ toggle({
171
+ kind: src.kind,
172
+ source: src.source,
173
+ externalId: r.externalId,
174
+ title: r.title,
175
+ subtitle: 'status' in r && r.status ? r.status : src.label,
176
+ icon: src.icon,
177
+ needsImport: true,
178
+ })
179
+ }
180
+
181
+ // Already-imported items for the selected source, for quick re-attaching without
182
+ // a round-trip. Excludes anything already pending.
183
+ const imported = computed<PendingContext[]>(() => {
184
+ const src = selected.value
185
+ if (!src) return []
186
+ const items: PendingContext[] =
187
+ src.kind === 'document'
188
+ ? documents.documents
189
+ .filter((d) => d.source === src.source)
190
+ .map((d) => ({
191
+ kind: 'document' as const,
192
+ source: d.source,
193
+ externalId: d.externalId,
194
+ title: d.title,
195
+ subtitle: src.label,
196
+ icon: src.icon,
197
+ needsImport: false,
198
+ }))
199
+ : tasks.tasks
200
+ .filter((t) => t.source === src.source)
201
+ .map((t) => ({
202
+ kind: 'task' as const,
203
+ source: t.source,
204
+ externalId: t.externalId,
205
+ title: `${t.externalId} · ${t.title}`,
206
+ subtitle: t.status || src.label,
207
+ icon: src.icon,
208
+ needsImport: false,
209
+ }))
210
+ return items.filter((i) => !selectedKeys.value.has(contextKey(i)))
211
+ })
212
+
213
+ function connect(src: SourceOption) {
214
+ if (src.kind === 'document') ui.openDocumentConnect(src.source as DocumentSourceKind)
215
+ else ui.openTaskConnect(src.source as TaskSourceKind)
216
+ toast.add({
217
+ title: `Connect ${src.label} — it'll be ready here once connected`,
218
+ icon: 'i-lucide-plug',
219
+ })
220
+ }
221
+ </script>
222
+
223
+ <template>
224
+ <div v-if="sources.length" class="space-y-2">
225
+ <div class="flex items-center gap-2">
226
+ <UDropdownMenu :items="sourceMenu" class="shrink-0">
227
+ <UButton
228
+ color="neutral"
229
+ variant="subtle"
230
+ size="sm"
231
+ :icon="selected?.icon ?? 'i-lucide-link'"
232
+ trailing-icon="i-lucide-chevron-down"
233
+ >
234
+ {{ selected?.label ?? 'Source' }}
235
+ </UButton>
236
+ </UDropdownMenu>
237
+
238
+ <UInput
239
+ v-if="selected?.connected"
240
+ v-model="query"
241
+ :placeholder="
242
+ selected?.searchable
243
+ ? `Search ${selected?.label} or paste a URL…`
244
+ : selected?.refPlaceholder
245
+ "
246
+ class="flex-1"
247
+ :loading="searching"
248
+ icon="i-lucide-search"
249
+ @keydown.enter.prevent="addByRef"
250
+ />
251
+
252
+ <UDropdownMenu :items="connectMenu" class="ml-auto shrink-0">
253
+ <UButton
254
+ color="neutral"
255
+ variant="ghost"
256
+ size="sm"
257
+ icon="i-lucide-plus"
258
+ trailing-icon="i-lucide-chevron-down"
259
+ title="Connect an integration"
260
+ >
261
+ Connect a source
262
+ </UButton>
263
+ </UDropdownMenu>
264
+ </div>
265
+
266
+ <!-- not-connected affordance -->
267
+ <div
268
+ v-if="selected && !selected.connected"
269
+ class="flex items-center justify-between rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-400"
270
+ >
271
+ <span>{{ selected.label }} isn't connected yet.</span>
272
+ <UButton
273
+ color="neutral"
274
+ variant="soft"
275
+ size="xs"
276
+ icon="i-lucide-plug"
277
+ @click="connect(selected)"
278
+ >
279
+ Connect
280
+ </UButton>
281
+ </div>
282
+
283
+ <!-- search results + paste-by-URL -->
284
+ <div v-if="selected?.connected && query.trim()" class="space-y-1">
285
+ <button
286
+ type="button"
287
+ class="flex w-full items-center gap-1.5 rounded-md border border-dashed border-slate-700 bg-slate-900/40 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
288
+ @click="addByRef"
289
+ >
290
+ <UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
291
+ <span class="truncate">Link “{{ query.trim() }}” by URL or id</span>
292
+ </button>
293
+ <button
294
+ v-for="r in results"
295
+ :key="`${r.source}:${r.externalId}`"
296
+ type="button"
297
+ class="w-full rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
298
+ @click="pickResult(selected!, r)"
299
+ >
300
+ <span class="flex items-center gap-1.5">
301
+ <UIcon
302
+ :name="selected?.icon ?? 'i-lucide-file-text'"
303
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
304
+ />
305
+ <span class="truncate">{{ r.title }}</span>
306
+ <UBadge
307
+ v-if="'status' in r && r.status"
308
+ color="neutral"
309
+ variant="soft"
310
+ size="xs"
311
+ class="ml-auto shrink-0"
312
+ >
313
+ {{ r.status }}
314
+ </UBadge>
315
+ </span>
316
+ <span v-if="r.excerpt" class="mt-0.5 block truncate pl-5 text-[11px] text-slate-500">
317
+ {{ r.excerpt }}
318
+ </span>
319
+ </button>
320
+ <p
321
+ v-if="selected?.searchable && !searching && !results.length"
322
+ class="px-1 text-[11px] text-slate-500"
323
+ >
324
+ No matches — or paste the exact URL/id above.
325
+ </p>
326
+ </div>
327
+
328
+ <!-- already-imported quick pick -->
329
+ <div v-if="imported.length" class="space-y-1">
330
+ <span class="px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-500">
331
+ Already imported
332
+ </span>
333
+ <button
334
+ v-for="item in imported"
335
+ :key="contextKey(item)"
336
+ type="button"
337
+ class="flex w-full items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/60"
338
+ @click="toggle(item)"
339
+ >
340
+ <UIcon
341
+ :name="item.icon ?? 'i-lucide-file-text'"
342
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
343
+ />
344
+ <span class="truncate">{{ item.title }}</span>
345
+ </button>
346
+ </div>
347
+
348
+ <!-- chosen items -->
349
+ <div v-if="model.length" class="flex flex-wrap gap-1.5">
350
+ <span
351
+ v-for="item in model"
352
+ :key="contextKey(item)"
353
+ class="flex max-w-full items-center gap-1 rounded-full border border-indigo-500/60 bg-indigo-500/10 px-2 py-0.5 text-[11px] text-slate-200"
354
+ >
355
+ <UIcon :name="item.icon ?? 'i-lucide-link'" class="h-3 w-3 shrink-0 text-indigo-400" />
356
+ <span class="truncate">{{ item.title }}</span>
357
+ <button
358
+ type="button"
359
+ class="shrink-0 text-slate-400 hover:text-slate-200"
360
+ @click="toggle(item)"
361
+ >
362
+ <UIcon name="i-lucide-x" class="h-3 w-3" />
363
+ </button>
364
+ </span>
365
+ </div>
366
+ </div>
367
+ </template>