@cat-factory/app 1.0.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 (95) 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 +18 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AgentFailureCard.vue +97 -0
  10. package/app/components/board/AgentStopButton.vue +61 -0
  11. package/app/components/board/BoardCanvas.vue +146 -0
  12. package/app/components/board/TaskDependencyEdges.vue +132 -0
  13. package/app/components/board/nodes/AgentChip.vue +59 -0
  14. package/app/components/board/nodes/BlockNode.vue +347 -0
  15. package/app/components/board/nodes/DecisionBadge.vue +21 -0
  16. package/app/components/board/nodes/DraggableTask.vue +69 -0
  17. package/app/components/board/nodes/ModuleFrame.vue +70 -0
  18. package/app/components/board/nodes/TaskCard.vue +237 -0
  19. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  20. package/app/components/documents/DocumentImportModal.vue +161 -0
  21. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  22. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  23. package/app/components/documents/TaskContextDocs.vue +83 -0
  24. package/app/components/focus/BlockFocusView.vue +161 -0
  25. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  26. package/app/components/github/GitHubConnect.vue +183 -0
  27. package/app/components/github/GitHubPanel.vue +584 -0
  28. package/app/components/layout/BoardSwitcher.vue +202 -0
  29. package/app/components/layout/BoardToolbar.vue +109 -0
  30. package/app/components/layout/SideBar.vue +193 -0
  31. package/app/components/layout/SpendWarningBanner.vue +107 -0
  32. package/app/components/palettes/AgentPalette.vue +33 -0
  33. package/app/components/palettes/BlockPalette.vue +41 -0
  34. package/app/components/palettes/PipelinePalette.vue +74 -0
  35. package/app/components/panels/DecisionModal.vue +71 -0
  36. package/app/components/panels/InspectorPanel.vue +296 -0
  37. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  38. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  39. package/app/components/panels/inspector/TaskExecution.vue +175 -0
  40. package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
  41. package/app/components/panels/inspector/TaskStructure.vue +139 -0
  42. package/app/components/pipeline/PipelineBuilder.vue +227 -0
  43. package/app/components/pipeline/PipelineProgress.vue +246 -0
  44. package/app/components/requirements/RequirementReviewModal.vue +328 -0
  45. package/app/components/scenarios/FeatureScenarios.vue +162 -0
  46. package/app/components/scenarios/ScenarioCard.vue +109 -0
  47. package/app/components/tasks/TaskContextIssues.vue +88 -0
  48. package/app/components/tasks/TaskImportModal.vue +140 -0
  49. package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
  50. package/app/composables/useApi.ts +535 -0
  51. package/app/composables/useBlockDrag.ts +75 -0
  52. package/app/composables/useBlockQueries.ts +136 -0
  53. package/app/composables/useBoardFlow.ts +11 -0
  54. package/app/composables/useDepLabels.ts +26 -0
  55. package/app/composables/useSemanticZoom.ts +16 -0
  56. package/app/composables/useWorkspaceStream.ts +125 -0
  57. package/app/docs/architecture.md +31 -0
  58. package/app/pages/index.vue +80 -0
  59. package/app/stores/accounts.ts +64 -0
  60. package/app/stores/agentRuns.ts +117 -0
  61. package/app/stores/agents.ts +40 -0
  62. package/app/stores/auth.ts +97 -0
  63. package/app/stores/board.spec.ts +197 -0
  64. package/app/stores/board.ts +147 -0
  65. package/app/stores/bootstrap.ts +97 -0
  66. package/app/stores/documents.ts +165 -0
  67. package/app/stores/execution.ts +115 -0
  68. package/app/stores/fragmentLibrary.ts +147 -0
  69. package/app/stores/fragments.ts +40 -0
  70. package/app/stores/github.ts +291 -0
  71. package/app/stores/models.ts +48 -0
  72. package/app/stores/pipelines.ts +77 -0
  73. package/app/stores/requirements.ts +133 -0
  74. package/app/stores/scenarios.spec.ts +82 -0
  75. package/app/stores/scenarios.ts +196 -0
  76. package/app/stores/tasks.spec.ts +71 -0
  77. package/app/stores/tasks.ts +149 -0
  78. package/app/stores/ui.ts +204 -0
  79. package/app/stores/workspace.ts +201 -0
  80. package/app/types/accounts.ts +38 -0
  81. package/app/types/bootstrap.ts +83 -0
  82. package/app/types/documents.ts +92 -0
  83. package/app/types/domain.ts +216 -0
  84. package/app/types/execution.ts +110 -0
  85. package/app/types/fragments.ts +72 -0
  86. package/app/types/github.ts +153 -0
  87. package/app/types/models.ts +48 -0
  88. package/app/types/requirements.ts +38 -0
  89. package/app/types/scenarios.ts +36 -0
  90. package/app/types/tasks.ts +67 -0
  91. package/app/utils/catalog.spec.ts +82 -0
  92. package/app/utils/catalog.ts +185 -0
  93. package/app/utils/dnd.ts +29 -0
  94. package/nuxt.config.ts +43 -0
  95. package/package.json +43 -0
@@ -0,0 +1,162 @@
1
+ <script setup lang="ts">
2
+ import type { Block } from '~/types/domain'
3
+ import ScenarioCard from '~/components/scenarios/ScenarioCard.vue'
4
+
5
+ // The current set of acceptance scenarios for a task, grouped by the feature
6
+ // each verifies. Scenarios are drafted from the task's requirements (its
7
+ // description + any linked PRDs) by the acceptance agent, then refined here.
8
+ // "Generate Playwright tests" is idempotent: it only covers scenarios that do
9
+ // not already have a test, matching the playwright agent's additive contract.
10
+ const props = defineProps<{ block: Block }>()
11
+
12
+ const scenarios = useScenariosStore()
13
+ const documents = useDocumentsStore()
14
+ const board = useBoardStore()
15
+ const toast = useToast()
16
+
17
+ const features = computed(() => props.block.features ?? [])
18
+
19
+ // Where this block's generated tests run. The choice is folded into the
20
+ // acceptance-testing agents' prompt at run time.
21
+ const TEST_TARGETS = [
22
+ { value: 'github_actions', label: 'Project CI', icon: 'i-lucide-github' },
23
+ { value: 'ephemeral_env', label: 'Ephemeral env', icon: 'i-lucide-container' },
24
+ ] as const
25
+
26
+ function setTarget(value: Block['testTarget']) {
27
+ board.updateBlock(props.block.id, { testTarget: value })
28
+ }
29
+
30
+ /** Requirement context fed to scenario generation: the block intent + PRD titles. */
31
+ function requirementsFor(): string[] {
32
+ const docs = documents.available ? documents.docsForBlock(props.block.id) : []
33
+ return docs.map((d) => d.title)
34
+ }
35
+
36
+ function draft(feature: string) {
37
+ const created = scenarios.generateForFeature(feature, {
38
+ description: props.block.description,
39
+ requirements: requirementsFor(),
40
+ })
41
+ toast.add({
42
+ title: created.length
43
+ ? `Drafted ${created.length} scenario${created.length === 1 ? '' : 's'}`
44
+ : 'Scenarios already drafted',
45
+ description: created.length
46
+ ? `From the requirements for “${feature}”.`
47
+ : 'Every standard scenario for this feature already exists.',
48
+ icon: 'i-lucide-clipboard-check',
49
+ })
50
+ }
51
+
52
+ function generateTests(feature: string) {
53
+ const created = scenarios.generatePlaywrightTests(feature)
54
+ toast.add({
55
+ title: created.length
56
+ ? `Generated ${created.length} Playwright test${created.length === 1 ? '' : 's'}`
57
+ : 'No new tests needed',
58
+ description: created.length
59
+ ? 'New test files committed for scenarios that lacked one.'
60
+ : 'Every scenario already has a Playwright test.',
61
+ icon: 'i-lucide-theater',
62
+ })
63
+ }
64
+
65
+ function addBlank(feature: string) {
66
+ scenarios.addScenario({ feature })
67
+ }
68
+ </script>
69
+
70
+ <template>
71
+ <div class="space-y-3">
72
+ <div class="flex items-center justify-between">
73
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
74
+ Acceptance scenarios
75
+ </span>
76
+ </div>
77
+
78
+ <!-- where the generated tests run (per block) -->
79
+ <div>
80
+ <div class="mb-1 text-[11px] text-slate-500">Run tests in</div>
81
+ <div class="flex gap-1">
82
+ <UButton
83
+ v-for="target in TEST_TARGETS"
84
+ :key="target.value"
85
+ :color="block.testTarget === target.value ? 'primary' : 'neutral'"
86
+ :variant="block.testTarget === target.value ? 'soft' : 'ghost'"
87
+ size="xs"
88
+ :icon="target.icon"
89
+ class="flex-1 justify-center"
90
+ @click="setTarget(target.value)"
91
+ >
92
+ {{ target.label }}
93
+ </UButton>
94
+ </div>
95
+ </div>
96
+
97
+ <p v-if="!features.length" class="text-[11px] text-slate-500">
98
+ Add a feature above to draft acceptance scenarios from this task's requirements.
99
+ </p>
100
+
101
+ <div v-for="feature in features" :key="feature" class="space-y-2">
102
+ <!-- feature header + actions -->
103
+ <div class="flex items-center gap-1.5">
104
+ <UIcon name="i-lucide-puzzle" class="h-3.5 w-3.5 shrink-0 text-emerald-400" />
105
+ <span class="truncate text-xs font-medium text-slate-200">{{ feature }}</span>
106
+ <UBadge color="neutral" variant="subtle" size="sm">
107
+ {{ scenarios.scenariosForFeature(feature).length }}
108
+ </UBadge>
109
+ <div class="ml-auto flex items-center gap-1">
110
+ <UButton
111
+ color="primary"
112
+ variant="ghost"
113
+ size="xs"
114
+ icon="i-lucide-clipboard-check"
115
+ title="Draft scenarios from requirements"
116
+ @click="draft(feature)"
117
+ />
118
+ <UButton
119
+ color="neutral"
120
+ variant="ghost"
121
+ size="xs"
122
+ icon="i-lucide-plus"
123
+ title="Add a blank scenario"
124
+ @click="addBlank(feature)"
125
+ />
126
+ <UButton
127
+ v-if="scenarios.scenariosForFeature(feature).length"
128
+ color="neutral"
129
+ variant="soft"
130
+ size="xs"
131
+ icon="i-lucide-theater"
132
+ :title="`Generate Playwright tests (${scenarios.untested(feature)} new)`"
133
+ @click="generateTests(feature)"
134
+ >
135
+ Tests
136
+ <UBadge
137
+ v-if="scenarios.untested(feature)"
138
+ color="primary"
139
+ variant="solid"
140
+ size="sm"
141
+ class="ml-0.5"
142
+ >
143
+ {{ scenarios.untested(feature) }}
144
+ </UBadge>
145
+ </UButton>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- the current set of scenarios for this feature -->
150
+ <div v-if="scenarios.scenariosForFeature(feature).length" class="space-y-2">
151
+ <ScenarioCard
152
+ v-for="scenario in scenarios.scenariosForFeature(feature)"
153
+ :key="scenario.id"
154
+ :scenario="scenario"
155
+ />
156
+ </div>
157
+ <p v-else class="pl-5 text-[11px] text-slate-500">
158
+ No scenarios yet — draft them from requirements or add one.
159
+ </p>
160
+ </div>
161
+ </div>
162
+ </template>
@@ -0,0 +1,109 @@
1
+ <script setup lang="ts">
2
+ import type { AcceptanceScenario } from '~/types/domain'
3
+
4
+ // A single, editable acceptance scenario: title plus Given / When / Then. The
5
+ // scenario is generated by the acceptance agent but fully editable afterwards;
6
+ // edits write straight back to the scenarios store. The Playwright badge shows
7
+ // whether an e2e test has been generated for it yet.
8
+ const props = defineProps<{ scenario: AcceptanceScenario }>()
9
+
10
+ const scenarios = useScenariosStore()
11
+
12
+ /** Bind a string[] clause to a newline-joined textarea (blank lines dropped). */
13
+ function clauseModel(key: 'given' | 'when' | 'then') {
14
+ return computed<string>({
15
+ get: () => props.scenario[key].join('\n'),
16
+ set: (value) =>
17
+ scenarios.updateScenario(props.scenario.id, {
18
+ [key]: value
19
+ .split('\n')
20
+ .map((line) => line.trim())
21
+ .filter(Boolean),
22
+ }),
23
+ })
24
+ }
25
+
26
+ const given = clauseModel('given')
27
+ const when = clauseModel('when')
28
+ const then = clauseModel('then')
29
+
30
+ const title = computed<string>({
31
+ get: () => props.scenario.title,
32
+ set: (value) => scenarios.updateScenario(props.scenario.id, { title: value }),
33
+ })
34
+
35
+ const approved = computed(() => props.scenario.status === 'approved')
36
+ function toggleStatus() {
37
+ scenarios.updateScenario(props.scenario.id, { status: approved.value ? 'draft' : 'approved' })
38
+ }
39
+
40
+ const clauses = [
41
+ { key: 'given', label: 'Given', model: given, placeholder: 'a precondition per line' },
42
+ { key: 'when', label: 'When', model: when, placeholder: 'the user action' },
43
+ { key: 'then', label: 'Then', model: then, placeholder: 'an expected outcome per line' },
44
+ ] as const
45
+ </script>
46
+
47
+ <template>
48
+ <div class="rounded-lg border border-slate-800 bg-slate-900/60 p-2.5 space-y-2">
49
+ <div class="flex items-center gap-1.5">
50
+ <UInput
51
+ v-model="title"
52
+ size="xs"
53
+ variant="none"
54
+ class="flex-1 font-medium text-white"
55
+ :ui="{ base: 'px-0' }"
56
+ placeholder="Scenario title"
57
+ />
58
+ <UBadge
59
+ :color="scenario.hasPlaywrightTest ? 'success' : 'neutral'"
60
+ variant="subtle"
61
+ size="sm"
62
+ :title="
63
+ scenario.hasPlaywrightTest
64
+ ? 'A Playwright test exists for this scenario'
65
+ : 'No Playwright test yet'
66
+ "
67
+ >
68
+ <UIcon
69
+ :name="
70
+ scenario.hasPlaywrightTest ? 'i-lucide-flask-conical' : 'i-lucide-flask-conical-off'
71
+ "
72
+ class="h-3 w-3"
73
+ />
74
+ </UBadge>
75
+ <UButton
76
+ :color="approved ? 'success' : 'neutral'"
77
+ variant="ghost"
78
+ size="xs"
79
+ :icon="approved ? 'i-lucide-check-circle-2' : 'i-lucide-circle-dashed'"
80
+ :title="approved ? 'Approved' : 'Draft'"
81
+ @click="toggleStatus"
82
+ />
83
+ <UButton
84
+ color="error"
85
+ variant="ghost"
86
+ size="xs"
87
+ icon="i-lucide-trash-2"
88
+ title="Delete scenario"
89
+ @click="scenarios.removeScenario(scenario.id)"
90
+ />
91
+ </div>
92
+
93
+ <div v-for="clause in clauses" :key="clause.key" class="flex gap-2">
94
+ <span
95
+ class="w-10 shrink-0 pt-1 text-[10px] font-semibold uppercase tracking-wide text-teal-400"
96
+ >
97
+ {{ clause.label }}
98
+ </span>
99
+ <UTextarea
100
+ v-model="clause.model.value"
101
+ :rows="1"
102
+ autoresize
103
+ size="xs"
104
+ class="flex-1"
105
+ :placeholder="clause.placeholder"
106
+ />
107
+ </div>
108
+ </div>
109
+ </template>
@@ -0,0 +1,88 @@
1
+ <script setup lang="ts">
2
+ // Inspector section for a task block: the tracker issues (Jira, …) attached to
3
+ // it as agent context, plus an "Attach" menu to link an already-imported issue
4
+ // or open the import modal. Mirrors TaskContextDocs.vue; shown only when the
5
+ // task-source integration is available. Each linked issue shows its status so
6
+ // the structured nature of an issue is visible at a glance.
7
+ import type { DropdownMenuItem } from '@nuxt/ui'
8
+ import type { Block, TaskSourceKind } from '~/types/domain'
9
+
10
+ const props = defineProps<{ block: Block }>()
11
+
12
+ const tasks = useTasksStore()
13
+ const ui = useUiStore()
14
+ const toast = useToast()
15
+
16
+ onMounted(() => {
17
+ tasks.loadTasks().catch(() => {})
18
+ })
19
+
20
+ const linked = computed(() => tasks.tasksForBlock(props.block.id))
21
+
22
+ async function attach(source: TaskSourceKind, externalId: string) {
23
+ try {
24
+ await tasks.linkToBlock(props.block.id, source, externalId)
25
+ toast.add({ title: 'Issue attached', icon: 'i-lucide-link' })
26
+ } catch (e) {
27
+ toast.add({
28
+ title: 'Could not attach',
29
+ description: e instanceof Error ? e.message : String(e),
30
+ icon: 'i-lucide-triangle-alert',
31
+ color: 'error',
32
+ })
33
+ }
34
+ }
35
+
36
+ const attachMenu = computed<DropdownMenuItem[][]>(() => {
37
+ const linkedKeys = new Set(linked.value.map((t) => `${t.source}:${t.externalId}`))
38
+ const items: DropdownMenuItem[] = tasks.tasks
39
+ .filter((t) => !linkedKeys.has(`${t.source}:${t.externalId}`))
40
+ .map((t) => ({
41
+ label: `${t.externalId} · ${t.title}`,
42
+ icon: tasks.descriptorFor(t.source)?.icon ?? 'i-lucide-square-check',
43
+ onSelect: () => attach(t.source, t.externalId),
44
+ }))
45
+ items.push({
46
+ label: 'Import an issue…',
47
+ icon: 'i-lucide-file-down',
48
+ onSelect: () => ui.openTaskImport(),
49
+ })
50
+ return [items]
51
+ })
52
+ </script>
53
+
54
+ <template>
55
+ <div v-if="tasks.available" class="space-y-2">
56
+ <div class="flex items-center justify-between">
57
+ <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
58
+ Context issues
59
+ </span>
60
+ <UDropdownMenu :items="attachMenu" :content="{ side: 'bottom', align: 'end' }">
61
+ <UButton color="neutral" variant="soft" size="xs" icon="i-lucide-plus">Attach</UButton>
62
+ </UDropdownMenu>
63
+ </div>
64
+
65
+ <div v-if="linked.length" class="space-y-1">
66
+ <a
67
+ v-for="task in linked"
68
+ :key="`${task.source}:${task.externalId}`"
69
+ :href="task.url"
70
+ target="_blank"
71
+ rel="noopener"
72
+ class="flex items-center gap-1.5 rounded-md border border-slate-800 bg-slate-900/60 px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-800/60"
73
+ >
74
+ <UIcon
75
+ :name="tasks.descriptorFor(task.source)?.icon ?? 'i-lucide-square-check'"
76
+ class="h-3.5 w-3.5 shrink-0 text-indigo-400"
77
+ />
78
+ <span class="truncate">{{ task.externalId }} · {{ task.title }}</span>
79
+ <UBadge color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">
80
+ {{ task.status }}
81
+ </UBadge>
82
+ </a>
83
+ </div>
84
+ <p v-else class="text-[11px] text-slate-500">
85
+ Attach a Jira issue so agents see its description and comments while implementing this task.
86
+ </p>
87
+ </div>
88
+ </template>
@@ -0,0 +1,140 @@
1
+ <script setup lang="ts">
2
+ // Import an issue from a connected task source (by key or URL) and review the
3
+ // issues already imported into the workspace. Unlike the document import flow
4
+ // there is no plan/spawn — issues are attached to a task block for context from
5
+ // the inspector (see TaskContextIssues.vue).
6
+ import type { TaskSourceKind } from '~/types/domain'
7
+
8
+ const ui = useUiStore()
9
+ const tasks = useTasksStore()
10
+ const toast = useToast()
11
+
12
+ const open = computed({
13
+ get: () => ui.taskImport !== null,
14
+ set: (v: boolean) => {
15
+ if (!v) ui.closeTaskImport()
16
+ },
17
+ })
18
+
19
+ const source = ref<TaskSourceKind | undefined>(undefined)
20
+ const ref_ = ref('')
21
+ const importing = ref(false)
22
+
23
+ const sourceItems = computed(() =>
24
+ tasks.connectedSources.map((s) => ({ label: s.label, value: s.source })),
25
+ )
26
+ const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
27
+
28
+ const sourceTasks = computed(() =>
29
+ source.value ? tasks.tasks.filter((t) => t.source === source.value) : [],
30
+ )
31
+
32
+ watch(open, (isOpen) => {
33
+ if (isOpen) {
34
+ ref_.value = ''
35
+ source.value = ui.taskImport?.source ?? tasks.connectedSources[0]?.source ?? undefined
36
+ tasks.loadTasks().catch(() => {})
37
+ }
38
+ })
39
+
40
+ async function doImport() {
41
+ const value = ref_.value.trim()
42
+ if (!value || !source.value) return
43
+ importing.value = true
44
+ try {
45
+ const task = await tasks.importTask(source.value, value)
46
+ ref_.value = ''
47
+ toast.add({ title: `Imported "${task.title}"`, icon: 'i-lucide-file-down' })
48
+ } catch (e) {
49
+ toast.add({
50
+ title: 'Import failed',
51
+ description: e instanceof Error ? e.message : String(e),
52
+ icon: 'i-lucide-triangle-alert',
53
+ color: 'error',
54
+ })
55
+ } finally {
56
+ importing.value = false
57
+ }
58
+ }
59
+ </script>
60
+
61
+ <template>
62
+ <UModal v-model:open="open" title="Import from a task source">
63
+ <template #body>
64
+ <!-- Empty state: no connections -->
65
+ <div v-if="!tasks.anyConnected" class="space-y-3 text-center">
66
+ <UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
67
+ <p class="text-sm text-slate-400">Connect a task source first.</p>
68
+ <div class="flex justify-center gap-2">
69
+ <UButton
70
+ v-for="s in tasks.sources"
71
+ :key="s.source"
72
+ color="primary"
73
+ variant="soft"
74
+ :icon="s.icon"
75
+ @click="ui.openTaskConnect(s.source)"
76
+ >
77
+ Connect {{ s.label }}
78
+ </UButton>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Main form -->
83
+ <div v-else class="space-y-4">
84
+ <UFormField v-if="sourceItems.length > 1" label="Source">
85
+ <USelect v-model="source" :items="sourceItems" class="w-full" />
86
+ </UFormField>
87
+
88
+ <div class="flex items-end gap-2">
89
+ <UFormField :label="descriptor?.refLabel ?? 'Issue key or URL'" class="flex-1">
90
+ <UInput
91
+ v-model="ref_"
92
+ :placeholder="descriptor?.refPlaceholder"
93
+ class="w-full"
94
+ @keydown.enter="doImport"
95
+ />
96
+ </UFormField>
97
+ <UButton
98
+ color="primary"
99
+ icon="i-lucide-file-down"
100
+ :loading="importing"
101
+ :disabled="!ref_.trim()"
102
+ @click="doImport"
103
+ >
104
+ Import
105
+ </UButton>
106
+ </div>
107
+
108
+ <!-- List of already-imported issues -->
109
+ <div v-if="sourceTasks.length" class="space-y-2">
110
+ <h3 class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
111
+ Imported issues
112
+ </h3>
113
+ <div
114
+ v-for="task in sourceTasks"
115
+ :key="`${task.source}:${task.externalId}`"
116
+ class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
117
+ >
118
+ <div class="flex items-start justify-between gap-2">
119
+ <div class="min-w-0">
120
+ <a
121
+ :href="task.url"
122
+ target="_blank"
123
+ rel="noopener"
124
+ class="truncate text-sm font-medium text-white hover:underline"
125
+ >
126
+ {{ task.externalId }} · {{ task.title }}
127
+ </a>
128
+ <p class="mt-0.5 line-clamp-2 text-xs text-slate-500">{{ task.excerpt }}</p>
129
+ </div>
130
+ <UBadge color="neutral" variant="soft" size="xs" class="shrink-0">
131
+ {{ task.status }}
132
+ </UBadge>
133
+ </div>
134
+ </div>
135
+ </div>
136
+ <p v-else class="text-center text-xs text-slate-500">No issues imported yet.</p>
137
+ </div>
138
+ </template>
139
+ </UModal>
140
+ </template>
@@ -0,0 +1,122 @@
1
+ <script setup lang="ts">
2
+ // Connect (or disconnect) the workspace to a task source. The form is rendered
3
+ // generically from the source's descriptor (credential fields), so the same
4
+ // modal serves Jira and any future tracker. Secret credentials are write-only —
5
+ // the backend never returns them, so on reload we show "Connected" with empty
6
+ // fields.
7
+ const ui = useUiStore()
8
+ const tasks = useTasksStore()
9
+ const toast = useToast()
10
+
11
+ const source = computed(() => ui.taskConnect?.source ?? null)
12
+ const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
13
+ const connection = computed(() => (source.value ? tasks.connectionFor(source.value) : undefined))
14
+ const connected = computed(() => connection.value !== undefined)
15
+
16
+ const open = computed({
17
+ get: () => ui.taskConnect !== null,
18
+ set: (v: boolean) => {
19
+ if (!v) ui.closeTaskConnect()
20
+ },
21
+ })
22
+
23
+ /** One value per credential field, reset whenever the modal (re)opens. */
24
+ const values = ref<Record<string, string>>({})
25
+ const saving = ref(false)
26
+
27
+ watch(open, (isOpen) => {
28
+ if (isOpen) values.value = {}
29
+ })
30
+
31
+ const canSubmit = computed(() => {
32
+ const fields = descriptor.value?.credentialFields ?? []
33
+ return fields.length > 0 && fields.every((f) => (values.value[f.key] ?? '').trim())
34
+ })
35
+
36
+ async function submit() {
37
+ if (!canSubmit.value || !source.value) return
38
+ const credentials: Record<string, string> = {}
39
+ for (const f of descriptor.value!.credentialFields) {
40
+ credentials[f.key] = values.value[f.key]!.trim()
41
+ }
42
+ saving.value = true
43
+ try {
44
+ await tasks.connect(source.value, credentials)
45
+ toast.add({
46
+ title: `${descriptor.value!.label} connected`,
47
+ icon: 'i-lucide-check',
48
+ color: 'success',
49
+ })
50
+ ui.closeTaskConnect()
51
+ } catch (e) {
52
+ toast.add({
53
+ title: 'Could not connect',
54
+ description: e instanceof Error ? e.message : String(e),
55
+ icon: 'i-lucide-triangle-alert',
56
+ color: 'error',
57
+ })
58
+ } finally {
59
+ saving.value = false
60
+ }
61
+ }
62
+
63
+ async function disconnect() {
64
+ if (!source.value) return
65
+ await tasks.disconnect(source.value)
66
+ toast.add({
67
+ title: `${descriptor.value?.label ?? 'Source'} disconnected`,
68
+ icon: 'i-lucide-unplug',
69
+ })
70
+ ui.closeTaskConnect()
71
+ }
72
+ </script>
73
+
74
+ <template>
75
+ <UModal v-model:open="open" :title="descriptor?.label ?? 'Connect source'">
76
+ <template #body>
77
+ <div v-if="descriptor" class="space-y-4">
78
+ <p class="text-sm text-slate-400">
79
+ Connect {{ descriptor.label }} to import issues and attach them to tasks as agent context.
80
+ </p>
81
+
82
+ <div class="space-y-3">
83
+ <UFormField
84
+ v-for="field in descriptor.credentialFields"
85
+ :key="field.key"
86
+ :label="field.label"
87
+ :help="field.help"
88
+ >
89
+ <UInput
90
+ v-model="values[field.key]"
91
+ :type="field.secret ? 'password' : 'text'"
92
+ :placeholder="field.placeholder"
93
+ class="w-full"
94
+ />
95
+ </UFormField>
96
+ </div>
97
+
98
+ <div class="flex items-center justify-between gap-2 pt-1">
99
+ <UButton
100
+ v-if="connected"
101
+ color="error"
102
+ variant="ghost"
103
+ icon="i-lucide-unplug"
104
+ @click="disconnect"
105
+ >
106
+ Disconnect
107
+ </UButton>
108
+ <div v-else />
109
+ <UButton
110
+ color="primary"
111
+ icon="i-lucide-plug"
112
+ :loading="saving"
113
+ :disabled="!canSubmit"
114
+ @click="submit"
115
+ >
116
+ {{ connected ? 'Update connection' : 'Connect' }}
117
+ </UButton>
118
+ </div>
119
+ </div>
120
+ </template>
121
+ </UModal>
122
+ </template>