@cat-factory/app 0.11.0 → 0.13.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.
- package/app/app.config.ts +18 -0
- package/app/components/board/AddTaskModal.vue +1 -1
- package/app/components/layout/CommandBar.vue +2 -2
- package/app/components/layout/IntegrationsHub.vue +6 -4
- package/app/components/panels/InspectorPanel.vue +15 -1
- package/app/components/panels/StepResultViewHost.vue +4 -0
- package/app/components/spec/ServiceSpecWindow.vue +348 -0
- package/app/components/tasks/TaskImportModal.vue +6 -6
- package/app/components/tasks/TaskSourceConnectModal.vue +74 -20
- package/app/composables/api/spec.ts +14 -0
- package/app/composables/api/tasks.ts +11 -4
- package/app/composables/useApi.ts +2 -0
- package/app/stores/serviceSpec.ts +72 -0
- package/app/stores/tasks.spec.ts +4 -2
- package/app/stores/tasks.ts +19 -5
- package/app/stores/ui.ts +5 -0
- package/app/types/spec.ts +74 -0
- package/app/types/tasks.ts +11 -0
- package/nuxt.config.ts +9 -0
- package/package.json +1 -1
package/app/app.config.ts
CHANGED
|
@@ -4,5 +4,23 @@ export default defineAppConfig({
|
|
|
4
4
|
primary: 'indigo',
|
|
5
5
|
neutral: 'slate',
|
|
6
6
|
},
|
|
7
|
+
// Give every overlay the same layered dark palette the agent-run-details
|
|
8
|
+
// reader uses: a deep slate-950 surface so the slate-900 panels/cards inside
|
|
9
|
+
// pop, with slate-800 chrome. Applies to all UModal/USlideover instances so
|
|
10
|
+
// overlays stay consistent without per-instance `:ui` overrides.
|
|
11
|
+
modal: {
|
|
12
|
+
slots: {
|
|
13
|
+
content: 'bg-slate-950 ring-slate-800 divide-slate-800',
|
|
14
|
+
header: 'border-b border-slate-800',
|
|
15
|
+
title: 'text-white',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
slideover: {
|
|
19
|
+
slots: {
|
|
20
|
+
content: 'bg-slate-950 ring-slate-800 divide-slate-800',
|
|
21
|
+
header: 'border-b border-slate-800',
|
|
22
|
+
title: 'text-white',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
7
25
|
},
|
|
8
26
|
})
|
|
@@ -187,7 +187,7 @@ const pendingContext = ref<PendingContext[]>([])
|
|
|
187
187
|
// always shown (ungated): when the relevant integration isn't connected the Attach
|
|
188
188
|
// button is disabled with a tooltip rather than the section being hidden.
|
|
189
189
|
const docsConnected = computed(() => documents.available && documents.anyConnected)
|
|
190
|
-
const issuesConnected = computed(() => tasks.available && tasks.
|
|
190
|
+
const issuesConnected = computed(() => tasks.available && tasks.anyOffered)
|
|
191
191
|
const pendingDocs = computed(() => pendingContext.value.filter((c) => c.kind === 'document'))
|
|
192
192
|
const pendingIssues = computed(() => pendingContext.value.filter((c) => c.kind === 'task'))
|
|
193
193
|
|
|
@@ -111,14 +111,14 @@ const commands = computed<Command[]>(() => {
|
|
|
111
111
|
for (const src of tasks.sources) {
|
|
112
112
|
list.push({
|
|
113
113
|
id: `task-connect-${src.source}`,
|
|
114
|
-
label:
|
|
114
|
+
label: src.available ? `Manage ${src.label}` : `Connect ${src.label}`,
|
|
115
115
|
group: 'Integrations',
|
|
116
116
|
icon: src.icon,
|
|
117
117
|
keywords: 'task source tracker issues',
|
|
118
118
|
run: () => ui.openTaskConnect(src.source),
|
|
119
119
|
})
|
|
120
120
|
}
|
|
121
|
-
if (tasks.
|
|
121
|
+
if (tasks.anyOffered) {
|
|
122
122
|
list.push({
|
|
123
123
|
id: 'task-import',
|
|
124
124
|
label: 'Import issues',
|
|
@@ -114,11 +114,13 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
114
114
|
icon: src.icon,
|
|
115
115
|
label: src.label,
|
|
116
116
|
description: `Link ${src.label} to import and reference tracker issues.`,
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// Available + enabled ⇒ offered (green); available + off ⇒ "Disabled";
|
|
118
|
+
// not available ⇒ no badge (Jira needs connecting; GitHub needs its App).
|
|
119
|
+
status: src.available ? (src.enabled ? undefined : 'Disabled') : undefined,
|
|
120
|
+
connected: src.available && src.enabled,
|
|
119
121
|
onClick: () => go(() => ui.openTaskConnect(src.source)),
|
|
120
122
|
}))
|
|
121
|
-
if (tasks.
|
|
123
|
+
if (tasks.anyOffered) {
|
|
122
124
|
trackers.push({
|
|
123
125
|
key: 'task:import',
|
|
124
126
|
icon: 'i-lucide-file-down',
|
|
@@ -207,7 +209,7 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
207
209
|
v-for="item in group.items"
|
|
208
210
|
:key="item.key"
|
|
209
211
|
type="button"
|
|
210
|
-
class="flex w-full items-center gap-3 rounded-lg border border-slate-
|
|
212
|
+
class="flex w-full items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-2.5 text-left transition hover:border-slate-700 hover:bg-slate-900"
|
|
211
213
|
@click="item.onClick()"
|
|
212
214
|
>
|
|
213
215
|
<UIcon :name="item.icon" class="h-5 w-5 shrink-0 text-slate-300" />
|
|
@@ -386,7 +386,7 @@ const showOriginalDescription = ref(false)
|
|
|
386
386
|
icon="i-lucide-ticket"
|
|
387
387
|
@click="ui.openTaskImport()"
|
|
388
388
|
>
|
|
389
|
-
{{ tasks.
|
|
389
|
+
{{ tasks.anyOffered ? 'Import issue' : 'Connect a tracker' }}
|
|
390
390
|
</UButton>
|
|
391
391
|
<UButton
|
|
392
392
|
v-if="isContainer && documents.available && documents.anyConnected"
|
|
@@ -406,6 +406,20 @@ const showOriginalDescription = ref(false)
|
|
|
406
406
|
<!-- task: context issues (tracker) -->
|
|
407
407
|
<TaskContextIssues v-if="isTask" :block="block" />
|
|
408
408
|
|
|
409
|
+
<!-- service (frame): navigate the prescriptive spec tree (+ Gherkin scenarios when
|
|
410
|
+
the spec is on the repo's default branch) -->
|
|
411
|
+
<UButton
|
|
412
|
+
v-if="isFrame"
|
|
413
|
+
block
|
|
414
|
+
color="neutral"
|
|
415
|
+
variant="soft"
|
|
416
|
+
size="sm"
|
|
417
|
+
icon="i-lucide-scroll-text"
|
|
418
|
+
@click="ui.openServiceSpec(block.id)"
|
|
419
|
+
>
|
|
420
|
+
View Requirements
|
|
421
|
+
</UButton>
|
|
422
|
+
|
|
409
423
|
<!-- service / module: tasks summary -->
|
|
410
424
|
<ContainerSummary v-if="isContainer" :block="block" />
|
|
411
425
|
<!-- service (frame): test infra + provisioning configuration -->
|
|
@@ -17,6 +17,7 @@ import TestReportWindow from '~/components/testing/TestReportWindow.vue'
|
|
|
17
17
|
import GateResultView from '~/components/gates/GateResultView.vue'
|
|
18
18
|
import ConsensusSessionWindow from '~/components/consensus/ConsensusSessionWindow.vue'
|
|
19
19
|
import GenericStructuredResultView from '~/components/panels/GenericStructuredResultView.vue'
|
|
20
|
+
import ServiceSpecWindow from '~/components/spec/ServiceSpecWindow.vue'
|
|
20
21
|
|
|
21
22
|
const ui = useUiStore()
|
|
22
23
|
|
|
@@ -31,6 +32,9 @@ const STEP_RESULT_VIEWS: Record<string, Component> = {
|
|
|
31
32
|
// Default dedicated view for a registered CUSTOM kind's structured (`custom`) output —
|
|
32
33
|
// a read-only JSON viewer, so a proprietary agent ships a result view with no bespoke code.
|
|
33
34
|
'generic-structured': GenericStructuredResultView,
|
|
35
|
+
// The service's prescriptive spec tree (+ Gherkin), opened from the inspector's "View
|
|
36
|
+
// Requirements" button. Not a pipeline-step view — opened directly via `ui.openServiceSpec`.
|
|
37
|
+
'service-spec': ServiceSpecWindow,
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
const active = computed<Component | null>(() => {
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Service-spec window — the dedicated surface for a service's prescriptive specification,
|
|
3
|
+
// opened from the inspector's "View Requirements" button (via the universal result-view
|
|
4
|
+
// host). It reads the sharded `spec/` artifact off the service repo's default branch and
|
|
5
|
+
// lets the human navigate the structured spec tree (modules → feature groups → requirements
|
|
6
|
+
// + acceptance criteria + domain rules). When the spec is present on main, a toggle switches
|
|
7
|
+
// to the rendered Gherkin scenarios (the seeded `.feature` files).
|
|
8
|
+
import type {
|
|
9
|
+
RequirementGroup,
|
|
10
|
+
RequirementItem,
|
|
11
|
+
RequirementPriority,
|
|
12
|
+
SpecModule,
|
|
13
|
+
} from '~/types/spec'
|
|
14
|
+
|
|
15
|
+
const board = useBoardStore()
|
|
16
|
+
const serviceSpec = useServiceSpecStore()
|
|
17
|
+
|
|
18
|
+
type ViewMode = 'structured' | 'gherkin'
|
|
19
|
+
const mode = ref<ViewMode>('structured')
|
|
20
|
+
// Selected feature group, keyed by its module + group index so a name collision can't
|
|
21
|
+
// cross-select. Null = show the service overview.
|
|
22
|
+
const selected = ref<{ m: number; g: number } | null>(null)
|
|
23
|
+
|
|
24
|
+
const { open, blockId, close } = useResultView('service-spec', {
|
|
25
|
+
onOpen: (id) => {
|
|
26
|
+
mode.value = 'structured'
|
|
27
|
+
selected.value = null
|
|
28
|
+
void serviceSpec.load(id)
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
|
|
33
|
+
const view = computed(() => (blockId.value ? serviceSpec.viewFor(blockId.value) : undefined))
|
|
34
|
+
const loading = computed(() => (blockId.value ? serviceSpec.isLoading(blockId.value) : false))
|
|
35
|
+
const errored = computed(() => (blockId.value ? serviceSpec.isErrored(blockId.value) : false))
|
|
36
|
+
const spec = computed(() => view.value?.spec ?? null)
|
|
37
|
+
const modules = computed<SpecModule[]>(() => spec.value?.modules ?? [])
|
|
38
|
+
const present = computed(() => !!view.value?.present && !!spec.value)
|
|
39
|
+
const hasGherkin = computed(() => (view.value?.features.length ?? 0) > 0)
|
|
40
|
+
|
|
41
|
+
// Auto-select the first non-empty group once a present spec is shown, so the main pane isn't
|
|
42
|
+
// empty. Depends on `blockId` as well as `present`: switching directly to ANOTHER already-cached
|
|
43
|
+
// present block (via the inspector, without closing) changes `blockId` while `present` stays
|
|
44
|
+
// true, so a watch on `present` alone would never re-fire and the second block would open on
|
|
45
|
+
// the empty Overview pane. `immediate` covers the first, uncached open (present flips false→true
|
|
46
|
+
// after the load). `onOpen` resets `selected` to null first (it runs before this watch, since
|
|
47
|
+
// `useResultView` registers its blockId watch earlier), so a stale selection never leaks across.
|
|
48
|
+
watch(
|
|
49
|
+
[present, blockId],
|
|
50
|
+
([is]) => {
|
|
51
|
+
if (is && !selected.value) {
|
|
52
|
+
const m = modules.value.findIndex((mod) => (mod.groups?.length ?? 0) > 0)
|
|
53
|
+
if (m >= 0) selected.value = { m, g: 0 }
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
{ immediate: true },
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
const selectedModule = computed<SpecModule | null>(() =>
|
|
60
|
+
selected.value ? (modules.value[selected.value.m] ?? null) : null,
|
|
61
|
+
)
|
|
62
|
+
const selectedGroup = computed<RequirementGroup | null>(() => {
|
|
63
|
+
if (!selected.value) return null
|
|
64
|
+
return selectedModule.value?.groups?.[selected.value.g] ?? null
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// The Gherkin `.feature` content matching the selected group. Features carry display names
|
|
68
|
+
// (not slugs), and the harness permits same-named groups in one module (only the on-disk
|
|
69
|
+
// SLUGS are collision-suffixed), so a plain name match can cross-select. When several
|
|
70
|
+
// features share the (module, group) name pair, disambiguate by the group's ordinal among
|
|
71
|
+
// its same-named siblings — both lists derive from the same name-sorted walk, so the ordinals
|
|
72
|
+
// line up.
|
|
73
|
+
const selectedFeature = computed(() => {
|
|
74
|
+
const mod = selectedModule.value
|
|
75
|
+
const grp = selectedGroup.value
|
|
76
|
+
if (!mod || !grp) return null
|
|
77
|
+
const matches =
|
|
78
|
+
view.value?.features.filter((f) => f.module === mod.name && f.group === grp.name) ?? []
|
|
79
|
+
if (matches.length <= 1) return matches[0] ?? null
|
|
80
|
+
const sameNamed = (mod.groups ?? []).filter((g) => g.name === grp.name)
|
|
81
|
+
const ordinal = sameNamed.indexOf(grp)
|
|
82
|
+
return matches[ordinal] ?? matches[0] ?? null
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
function selectGroup(m: number, g: number) {
|
|
86
|
+
selected.value = { m, g }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PRIORITY_META: Record<RequirementPriority, { label: string; chip: string }> = {
|
|
90
|
+
must: { label: 'Must', chip: 'error' },
|
|
91
|
+
should: { label: 'Should', chip: 'warning' },
|
|
92
|
+
could: { label: 'Could', chip: 'neutral' },
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function reqCount(group: RequirementGroup): number {
|
|
96
|
+
return group.requirements?.length ?? 0
|
|
97
|
+
}
|
|
98
|
+
function priorityMeta(item: RequirementItem) {
|
|
99
|
+
return PRIORITY_META[item.priority] ?? PRIORITY_META.could
|
|
100
|
+
}
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<template>
|
|
104
|
+
<Teleport to="body">
|
|
105
|
+
<div
|
|
106
|
+
v-if="open"
|
|
107
|
+
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/70 p-4 backdrop-blur-sm"
|
|
108
|
+
@click.self="close"
|
|
109
|
+
>
|
|
110
|
+
<div
|
|
111
|
+
class="flex h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl"
|
|
112
|
+
>
|
|
113
|
+
<!-- header -->
|
|
114
|
+
<header class="flex items-center gap-3 border-b border-slate-800 px-6 py-4">
|
|
115
|
+
<div
|
|
116
|
+
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500/15"
|
|
117
|
+
>
|
|
118
|
+
<UIcon name="i-lucide-scroll-text" class="h-5 w-5 text-indigo-300" />
|
|
119
|
+
</div>
|
|
120
|
+
<div class="min-w-0">
|
|
121
|
+
<h1 class="truncate text-base font-semibold text-white">Requirements</h1>
|
|
122
|
+
<p v-if="block" class="truncate text-xs text-slate-500">
|
|
123
|
+
{{ spec?.service || block.title }}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
<div class="ml-auto flex items-center gap-1.5">
|
|
127
|
+
<!-- view toggle: Gherkin only when the spec (and its feature files) are on main -->
|
|
128
|
+
<div v-if="present" class="flex items-center rounded-lg border border-slate-700 p-0.5">
|
|
129
|
+
<UButton
|
|
130
|
+
:color="mode === 'structured' ? 'primary' : 'neutral'"
|
|
131
|
+
:variant="mode === 'structured' ? 'soft' : 'ghost'"
|
|
132
|
+
size="xs"
|
|
133
|
+
icon="i-lucide-list-tree"
|
|
134
|
+
@click="mode = 'structured'"
|
|
135
|
+
>
|
|
136
|
+
Structured
|
|
137
|
+
</UButton>
|
|
138
|
+
<UButton
|
|
139
|
+
:color="mode === 'gherkin' ? 'primary' : 'neutral'"
|
|
140
|
+
:variant="mode === 'gherkin' ? 'soft' : 'ghost'"
|
|
141
|
+
size="xs"
|
|
142
|
+
icon="i-lucide-square-check-big"
|
|
143
|
+
:disabled="!hasGherkin"
|
|
144
|
+
:title="hasGherkin ? 'Gherkin scenarios' : 'No scenarios on main yet'"
|
|
145
|
+
@click="mode = 'gherkin'"
|
|
146
|
+
>
|
|
147
|
+
Gherkin
|
|
148
|
+
</UButton>
|
|
149
|
+
</div>
|
|
150
|
+
<UButton icon="i-lucide-x" color="neutral" variant="ghost" size="sm" @click="close" />
|
|
151
|
+
</div>
|
|
152
|
+
</header>
|
|
153
|
+
|
|
154
|
+
<!-- loading -->
|
|
155
|
+
<div
|
|
156
|
+
v-if="loading && !view"
|
|
157
|
+
class="flex flex-1 items-center justify-center gap-2 text-sm text-slate-400"
|
|
158
|
+
>
|
|
159
|
+
<UIcon name="i-lucide-loader-circle" class="h-4 w-4 animate-spin" />
|
|
160
|
+
Loading the specification…
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<!-- error -->
|
|
164
|
+
<div
|
|
165
|
+
v-else-if="errored"
|
|
166
|
+
class="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center text-sm text-slate-400"
|
|
167
|
+
>
|
|
168
|
+
<UIcon name="i-lucide-triangle-alert" class="h-6 w-6 text-amber-400" />
|
|
169
|
+
Could not load the specification. Try reopening this window.
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<!-- empty: no spec on the repo's default branch yet -->
|
|
173
|
+
<div
|
|
174
|
+
v-else-if="!present"
|
|
175
|
+
class="flex flex-1 flex-col items-center justify-center gap-3 p-8 text-center"
|
|
176
|
+
>
|
|
177
|
+
<UIcon name="i-lucide-scroll-text" class="h-8 w-8 text-slate-600" />
|
|
178
|
+
<div>
|
|
179
|
+
<p class="text-sm font-medium text-slate-300">No specification yet</p>
|
|
180
|
+
<p class="mx-auto mt-1 max-w-md text-xs text-slate-500">
|
|
181
|
+
This service has no spec on its default branch. A specification is generated as tasks
|
|
182
|
+
run their pipelines; once it lands on main it will appear here, with its Gherkin
|
|
183
|
+
scenarios.
|
|
184
|
+
</p>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<!-- spec body: navigable tree + detail -->
|
|
189
|
+
<div v-else class="flex min-h-0 flex-1">
|
|
190
|
+
<!-- nav: modules → feature groups -->
|
|
191
|
+
<nav class="w-64 shrink-0 overflow-y-auto border-r border-slate-800 px-3 py-4">
|
|
192
|
+
<UButton
|
|
193
|
+
block
|
|
194
|
+
class="mb-2 justify-start"
|
|
195
|
+
:color="selected === null ? 'primary' : 'neutral'"
|
|
196
|
+
:variant="selected === null ? 'soft' : 'ghost'"
|
|
197
|
+
size="xs"
|
|
198
|
+
icon="i-lucide-info"
|
|
199
|
+
@click="selected = null"
|
|
200
|
+
>
|
|
201
|
+
Overview
|
|
202
|
+
</UButton>
|
|
203
|
+
<div v-for="(mod, mi) in modules" :key="mi" class="mb-3">
|
|
204
|
+
<div
|
|
205
|
+
class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500"
|
|
206
|
+
>
|
|
207
|
+
{{ mod.name }}
|
|
208
|
+
</div>
|
|
209
|
+
<ul class="space-y-0.5">
|
|
210
|
+
<li v-for="(group, gi) in mod.groups ?? []" :key="gi">
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-[13px] transition"
|
|
214
|
+
:class="
|
|
215
|
+
selected?.m === mi && selected?.g === gi
|
|
216
|
+
? 'bg-indigo-500/15 text-indigo-200'
|
|
217
|
+
: 'text-slate-300 hover:bg-slate-800'
|
|
218
|
+
"
|
|
219
|
+
@click="selectGroup(mi, gi)"
|
|
220
|
+
>
|
|
221
|
+
<span class="truncate">{{ group.name }}</span>
|
|
222
|
+
<span class="shrink-0 text-[10px] text-slate-500">{{ reqCount(group) }}</span>
|
|
223
|
+
</button>
|
|
224
|
+
</li>
|
|
225
|
+
<li
|
|
226
|
+
v-if="(mod.groups?.length ?? 0) === 0"
|
|
227
|
+
class="px-2 py-1 text-[11px] italic text-slate-600"
|
|
228
|
+
>
|
|
229
|
+
No feature groups
|
|
230
|
+
</li>
|
|
231
|
+
</ul>
|
|
232
|
+
</div>
|
|
233
|
+
</nav>
|
|
234
|
+
|
|
235
|
+
<!-- detail -->
|
|
236
|
+
<div class="min-w-0 flex-1 overflow-y-auto px-6 py-5">
|
|
237
|
+
<!-- service overview -->
|
|
238
|
+
<template v-if="selected === null">
|
|
239
|
+
<h2 class="text-lg font-semibold text-white">{{ spec?.service }}</h2>
|
|
240
|
+
<p v-if="spec?.summary" class="mt-2 whitespace-pre-line text-sm text-slate-300">
|
|
241
|
+
{{ spec.summary }}
|
|
242
|
+
</p>
|
|
243
|
+
<p v-else class="mt-2 text-sm text-slate-500">No service summary.</p>
|
|
244
|
+
<p class="mt-4 text-xs text-slate-500">
|
|
245
|
+
{{ modules.length }} module(s). Select a feature group on the left to view its
|
|
246
|
+
requirements{{ hasGherkin ? ' or switch to the Gherkin scenarios' : '' }}.
|
|
247
|
+
</p>
|
|
248
|
+
</template>
|
|
249
|
+
|
|
250
|
+
<!-- selected feature group -->
|
|
251
|
+
<template v-else-if="selectedGroup">
|
|
252
|
+
<div class="mb-1 text-[11px] uppercase tracking-wide text-slate-500">
|
|
253
|
+
{{ selectedModule?.name }}
|
|
254
|
+
</div>
|
|
255
|
+
<h2 class="text-lg font-semibold text-white">{{ selectedGroup.name }}</h2>
|
|
256
|
+
<p v-if="selectedGroup.summary" class="mt-1 text-sm text-slate-400">
|
|
257
|
+
{{ selectedGroup.summary }}
|
|
258
|
+
</p>
|
|
259
|
+
|
|
260
|
+
<!-- GHERKIN view: the rendered .feature file for this group -->
|
|
261
|
+
<template v-if="mode === 'gherkin'">
|
|
262
|
+
<pre
|
|
263
|
+
v-if="selectedFeature"
|
|
264
|
+
class="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-950/60 p-4 text-[12.5px] leading-relaxed text-slate-200"
|
|
265
|
+
><code>{{ selectedFeature.content }}</code></pre>
|
|
266
|
+
<div
|
|
267
|
+
v-else
|
|
268
|
+
class="mt-4 rounded-lg border border-dashed border-slate-700 p-6 text-center text-sm text-slate-500"
|
|
269
|
+
>
|
|
270
|
+
No Gherkin scenarios for this group.
|
|
271
|
+
</div>
|
|
272
|
+
</template>
|
|
273
|
+
|
|
274
|
+
<!-- STRUCTURED view: requirements + acceptance + domain rules -->
|
|
275
|
+
<template v-else>
|
|
276
|
+
<div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-slate-500">
|
|
277
|
+
No requirements in this group.
|
|
278
|
+
</div>
|
|
279
|
+
<ul class="mt-4 space-y-4">
|
|
280
|
+
<li
|
|
281
|
+
v-for="req in selectedGroup.requirements ?? []"
|
|
282
|
+
:key="req.id"
|
|
283
|
+
class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
|
|
284
|
+
>
|
|
285
|
+
<div class="flex items-start justify-between gap-3">
|
|
286
|
+
<h3 class="text-sm font-semibold text-slate-100">{{ req.title }}</h3>
|
|
287
|
+
<div class="flex shrink-0 items-center gap-1.5">
|
|
288
|
+
<UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
|
|
289
|
+
{{ priorityMeta(req).label }}
|
|
290
|
+
</UBadge>
|
|
291
|
+
<UBadge color="neutral" variant="subtle" size="sm">{{ req.kind }}</UBadge>
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
<p
|
|
295
|
+
class="mt-1.5 whitespace-pre-line text-[13px] leading-relaxed text-slate-300"
|
|
296
|
+
>
|
|
297
|
+
{{ req.statement }}
|
|
298
|
+
</p>
|
|
299
|
+
<!-- acceptance criteria (Given/When/Then) -->
|
|
300
|
+
<div v-if="(req.acceptance?.length ?? 0) > 0" class="mt-3 space-y-1.5">
|
|
301
|
+
<div
|
|
302
|
+
v-for="ac in req.acceptance ?? []"
|
|
303
|
+
:key="ac.id"
|
|
304
|
+
class="rounded-md border border-slate-800 bg-slate-950/50 px-3 py-2 text-[12.5px] leading-relaxed"
|
|
305
|
+
>
|
|
306
|
+
<p class="text-slate-300">
|
|
307
|
+
<span class="font-semibold text-emerald-400">Given</span> {{ ac.given }}
|
|
308
|
+
</p>
|
|
309
|
+
<p class="text-slate-300">
|
|
310
|
+
<span class="font-semibold text-sky-400">When</span> {{ ac.when }}
|
|
311
|
+
</p>
|
|
312
|
+
<p class="text-slate-300">
|
|
313
|
+
<span class="font-semibold text-violet-400">Then</span> {{ ac.outcome }}
|
|
314
|
+
</p>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</li>
|
|
318
|
+
</ul>
|
|
319
|
+
|
|
320
|
+
<!-- domain rules / invariants scoped to this group -->
|
|
321
|
+
<div v-if="(selectedGroup.rules?.length ?? 0) > 0" class="mt-6">
|
|
322
|
+
<div
|
|
323
|
+
class="mb-2 flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
324
|
+
>
|
|
325
|
+
<UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5" />
|
|
326
|
+
Domain rules
|
|
327
|
+
</div>
|
|
328
|
+
<ul class="space-y-1.5">
|
|
329
|
+
<li
|
|
330
|
+
v-for="rule in selectedGroup.rules ?? []"
|
|
331
|
+
:key="rule.id"
|
|
332
|
+
class="rounded-md border border-slate-800 bg-slate-900/60 px-3 py-2 text-[13px] text-slate-300"
|
|
333
|
+
>
|
|
334
|
+
{{ rule.rule }}
|
|
335
|
+
<span v-if="rule.rationale" class="text-slate-500">
|
|
336
|
+
— {{ rule.rationale }}</span
|
|
337
|
+
>
|
|
338
|
+
</li>
|
|
339
|
+
</ul>
|
|
340
|
+
</div>
|
|
341
|
+
</template>
|
|
342
|
+
</template>
|
|
343
|
+
</div>
|
|
344
|
+
</div>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
</Teleport>
|
|
348
|
+
</template>
|
|
@@ -24,7 +24,7 @@ const ref_ = ref('')
|
|
|
24
24
|
const importing = ref(false)
|
|
25
25
|
|
|
26
26
|
const sourceItems = computed(() =>
|
|
27
|
-
tasks.
|
|
27
|
+
tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
28
|
)
|
|
29
29
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
30
30
|
|
|
@@ -52,7 +52,7 @@ const creatingId = ref<string | null>(null)
|
|
|
52
52
|
watch(open, (isOpen) => {
|
|
53
53
|
if (isOpen) {
|
|
54
54
|
ref_.value = ''
|
|
55
|
-
source.value = ui.taskImport?.source ?? tasks.
|
|
55
|
+
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
56
56
|
containerId.value = containerItems.value[0]?.value
|
|
57
57
|
creatingId.value = null
|
|
58
58
|
tasks.loadTasks().catch(() => {})
|
|
@@ -102,10 +102,10 @@ async function doImport() {
|
|
|
102
102
|
<template>
|
|
103
103
|
<UModal v-model:open="open" title="Import from a task source">
|
|
104
104
|
<template #body>
|
|
105
|
-
<!-- Empty state: no
|
|
106
|
-
<div v-if="!tasks.
|
|
105
|
+
<!-- Empty state: no source offered (none connected/installed, or all disabled) -->
|
|
106
|
+
<div v-if="!tasks.anyOffered" class="space-y-3 text-center">
|
|
107
107
|
<UIcon name="i-lucide-plug" class="mx-auto h-8 w-8 text-slate-500" />
|
|
108
|
-
<p class="text-sm text-slate-400">Connect a task source first.</p>
|
|
108
|
+
<p class="text-sm text-slate-400">Connect or enable a task source first.</p>
|
|
109
109
|
<div class="flex justify-center gap-2">
|
|
110
110
|
<UButton
|
|
111
111
|
v-for="s in tasks.sources"
|
|
@@ -115,7 +115,7 @@ async function doImport() {
|
|
|
115
115
|
:icon="s.icon"
|
|
116
116
|
@click="ui.openTaskConnect(s.source)"
|
|
117
117
|
>
|
|
118
|
-
|
|
118
|
+
{{ s.available ? `Enable ${s.label}` : `Connect ${s.label}` }}
|
|
119
119
|
</UButton>
|
|
120
120
|
</div>
|
|
121
121
|
</div>
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
// Connect
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// Connect/manage a task source for the workspace. The form is rendered generically
|
|
3
|
+
// from the source's descriptor (credential fields), so the same modal serves Jira
|
|
4
|
+
// and any future credentialed tracker. A credentialless source (GitHub Issues)
|
|
5
|
+
// has no form — it rides the workspace's installed GitHub App — so the modal just
|
|
6
|
+
// offers the on/off toggle. Secret credentials are write-only: the backend never
|
|
7
|
+
// returns them, so on reload we show "Connected" with empty fields.
|
|
8
|
+
//
|
|
9
|
+
// The on/off toggle is the per-workspace switch (persisted in task_source_settings):
|
|
10
|
+
// a workspace can offer GitHub repos without offering their issues, and can park a
|
|
11
|
+
// connected Jira without disconnecting it. The toggle only applies once a source is
|
|
12
|
+
// available (Jira connected / the GitHub App installed) — there is nothing to offer
|
|
13
|
+
// before that.
|
|
7
14
|
const ui = useUiStore()
|
|
8
15
|
const tasks = useTasksStore()
|
|
9
16
|
const toast = useToast()
|
|
@@ -12,6 +19,10 @@ const source = computed(() => ui.taskConnect?.source ?? null)
|
|
|
12
19
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
13
20
|
const connection = computed(() => (source.value ? tasks.connectionFor(source.value) : undefined))
|
|
14
21
|
const connected = computed(() => connection.value !== undefined)
|
|
22
|
+
// A credentialless source (GitHub Issues) reuses the installed GitHub App: no form.
|
|
23
|
+
const credentialless = computed(() => (descriptor.value?.credentialFields.length ?? 0) === 0)
|
|
24
|
+
// Usable right now: a credentialed source is connected; GitHub Issues' App is installed.
|
|
25
|
+
const available = computed(() => descriptor.value?.available ?? false)
|
|
15
26
|
|
|
16
27
|
const open = computed({
|
|
17
28
|
get: () => ui.taskConnect !== null,
|
|
@@ -23,24 +34,19 @@ const open = computed({
|
|
|
23
34
|
/** One value per credential field, reset whenever the modal (re)opens. */
|
|
24
35
|
const values = ref<Record<string, string>>({})
|
|
25
36
|
const saving = ref(false)
|
|
37
|
+
const togglingEnabled = ref(false)
|
|
26
38
|
|
|
27
39
|
watch(open, (isOpen) => {
|
|
28
40
|
if (isOpen) values.value = {}
|
|
29
41
|
})
|
|
30
42
|
|
|
31
|
-
// A source with no credential fields (e.g. GitHub, which reuses the workspace's
|
|
32
|
-
// installed GitHub App) connects with an empty bag — there is nothing to fill in,
|
|
33
|
-
// so the button is enabled as long as it isn't already connected.
|
|
34
|
-
const credentialless = computed(() => (descriptor.value?.credentialFields.length ?? 0) === 0)
|
|
35
|
-
|
|
36
43
|
const canSubmit = computed(() => {
|
|
37
44
|
const fields = descriptor.value?.credentialFields ?? []
|
|
38
|
-
if (credentialless.value) return !connected.value
|
|
39
45
|
return fields.every((f) => (values.value[f.key] ?? '').trim())
|
|
40
46
|
})
|
|
41
47
|
|
|
42
48
|
async function submit() {
|
|
43
|
-
if (!canSubmit.value || !source.value) return
|
|
49
|
+
if (!canSubmit.value || !source.value || credentialless.value) return
|
|
44
50
|
const credentials: Record<string, string> = {}
|
|
45
51
|
for (const f of descriptor.value!.credentialFields) {
|
|
46
52
|
credentials[f.key] = values.value[f.key]!.trim()
|
|
@@ -53,7 +59,8 @@ async function submit() {
|
|
|
53
59
|
icon: 'i-lucide-check',
|
|
54
60
|
color: 'success',
|
|
55
61
|
})
|
|
56
|
-
|
|
62
|
+
// Re-probe so `available`/`enabled` reflect the new connection.
|
|
63
|
+
await tasks.probe()
|
|
57
64
|
} catch (e) {
|
|
58
65
|
toast.add({
|
|
59
66
|
title: 'Could not connect',
|
|
@@ -69,28 +76,53 @@ async function submit() {
|
|
|
69
76
|
async function disconnect() {
|
|
70
77
|
if (!source.value) return
|
|
71
78
|
await tasks.disconnect(source.value)
|
|
79
|
+
await tasks.probe()
|
|
72
80
|
toast.add({
|
|
73
81
|
title: `${descriptor.value?.label ?? 'Source'} disconnected`,
|
|
74
82
|
icon: 'i-lucide-unplug',
|
|
75
83
|
})
|
|
76
84
|
ui.closeTaskConnect()
|
|
77
85
|
}
|
|
86
|
+
|
|
87
|
+
async function toggleEnabled(enabled: boolean) {
|
|
88
|
+
if (!source.value) return
|
|
89
|
+
togglingEnabled.value = true
|
|
90
|
+
try {
|
|
91
|
+
await tasks.setEnabled(source.value, enabled)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
toast.add({
|
|
94
|
+
title: 'Could not update',
|
|
95
|
+
description: e instanceof Error ? e.message : String(e),
|
|
96
|
+
icon: 'i-lucide-triangle-alert',
|
|
97
|
+
color: 'error',
|
|
98
|
+
})
|
|
99
|
+
} finally {
|
|
100
|
+
togglingEnabled.value = false
|
|
101
|
+
}
|
|
102
|
+
}
|
|
78
103
|
</script>
|
|
79
104
|
|
|
80
105
|
<template>
|
|
81
|
-
<UModal v-model:open="open" :title="descriptor?.label ?? '
|
|
106
|
+
<UModal v-model:open="open" :title="descriptor?.label ?? 'Task source'">
|
|
82
107
|
<template #body>
|
|
83
108
|
<div v-if="descriptor" class="space-y-4">
|
|
84
109
|
<p class="text-sm text-slate-400">
|
|
85
|
-
|
|
110
|
+
{{ descriptor.label }} lets you import issues and attach them to tasks as agent context.
|
|
86
111
|
</p>
|
|
87
112
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
<!-- Credentialless source (GitHub Issues): no form, just the on/off toggle. -->
|
|
114
|
+
<template v-if="credentialless">
|
|
115
|
+
<p class="text-[11px] text-slate-500">
|
|
116
|
+
This source uses the GitHub App already installed on your workspace — there are no
|
|
117
|
+
credentials to enter.
|
|
118
|
+
</p>
|
|
119
|
+
<p v-if="!available" class="text-[11px] text-amber-400">
|
|
120
|
+
Install the workspace's GitHub App (connect GitHub repos) to offer {{ descriptor.label }}.
|
|
121
|
+
</p>
|
|
122
|
+
</template>
|
|
92
123
|
|
|
93
|
-
|
|
124
|
+
<!-- Credentialed source (Jira): the connect form, shown until connected. -->
|
|
125
|
+
<div v-else-if="!connected" class="space-y-3">
|
|
94
126
|
<UFormField
|
|
95
127
|
v-for="field in descriptor.credentialFields"
|
|
96
128
|
:key="field.key"
|
|
@@ -105,6 +137,27 @@ async function disconnect() {
|
|
|
105
137
|
/>
|
|
106
138
|
</UFormField>
|
|
107
139
|
</div>
|
|
140
|
+
<p v-else class="text-[11px] text-slate-500">
|
|
141
|
+
Connected{{ connection?.label ? ` to ${connection.label}` : '' }}.
|
|
142
|
+
</p>
|
|
143
|
+
|
|
144
|
+
<!-- The per-workspace on/off toggle, available once the source is usable. -->
|
|
145
|
+
<div
|
|
146
|
+
v-if="available"
|
|
147
|
+
class="flex items-center justify-between gap-2 rounded-md border border-slate-800 px-3 py-2"
|
|
148
|
+
>
|
|
149
|
+
<div class="text-sm">
|
|
150
|
+
<div class="font-medium text-slate-200">Offer to this workspace</div>
|
|
151
|
+
<div class="text-[11px] text-slate-500">
|
|
152
|
+
When off, {{ descriptor.label }} is hidden from import and linking.
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
<USwitch
|
|
156
|
+
:model-value="descriptor.enabled"
|
|
157
|
+
:loading="togglingEnabled"
|
|
158
|
+
@update:model-value="toggleEnabled"
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
108
161
|
|
|
109
162
|
<div class="flex items-center justify-between gap-2 pt-1">
|
|
110
163
|
<UButton
|
|
@@ -118,6 +171,7 @@ async function disconnect() {
|
|
|
118
171
|
</UButton>
|
|
119
172
|
<div v-else />
|
|
120
173
|
<UButton
|
|
174
|
+
v-if="!credentialless"
|
|
121
175
|
color="primary"
|
|
122
176
|
icon="i-lucide-plug"
|
|
123
177
|
:loading="saving"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ServiceSpecView } from '~/types/spec'
|
|
2
|
+
import type { ApiContext } from './context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The service-spec read (the inspector's "View Requirements" window). Reassembles the
|
|
6
|
+
* sharded `spec/` artifact from the service repo's default branch. Always 200: a service
|
|
7
|
+
* with no spec on main (or no GitHub connected) returns `{ present: false }`.
|
|
8
|
+
*/
|
|
9
|
+
export function specApi({ http, ws }: ApiContext) {
|
|
10
|
+
return {
|
|
11
|
+
getServiceSpec: (workspaceId: string, blockId: string) =>
|
|
12
|
+
http<ServiceSpecView>(`${ws(workspaceId)}/blocks/${encodeURIComponent(blockId)}/spec`),
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -3,8 +3,8 @@ import type {
|
|
|
3
3
|
SourceTask,
|
|
4
4
|
TaskConnection,
|
|
5
5
|
TaskSearchResult,
|
|
6
|
-
TaskSourceDescriptor,
|
|
7
6
|
TaskSourceKind,
|
|
7
|
+
TaskSourceState,
|
|
8
8
|
} from '~/types/domain'
|
|
9
9
|
import type { PutTrackerSettingsInput, TrackerSettings } from '~/types/tracker'
|
|
10
10
|
import type { ApiContext } from './context'
|
|
@@ -13,10 +13,17 @@ import type { ApiContext } from './context'
|
|
|
13
13
|
export function tasksApi({ http, ws }: ApiContext) {
|
|
14
14
|
return {
|
|
15
15
|
// ---- task sources (Jira, …) ------------------------------------------
|
|
16
|
-
// The configured trackers + their connect/import metadata
|
|
17
|
-
//
|
|
16
|
+
// The configured trackers + their connect/import metadata + the workspace's
|
|
17
|
+
// per-source state (available + enabled). A 503 means the integration is off
|
|
18
|
+
// (the store hides its UI on any error here).
|
|
18
19
|
listTaskSources: (workspaceId: string) =>
|
|
19
|
-
http<{ sources:
|
|
20
|
+
http<{ sources: TaskSourceState[] }>(`${ws(workspaceId)}/task-sources`),
|
|
21
|
+
|
|
22
|
+
setTaskSourceEnabled: (workspaceId: string, source: TaskSourceKind, enabled: boolean) =>
|
|
23
|
+
http(`${ws(workspaceId)}/task-sources/${source}/enabled`, {
|
|
24
|
+
method: 'PUT',
|
|
25
|
+
body: { enabled },
|
|
26
|
+
}),
|
|
20
27
|
|
|
21
28
|
listTaskConnections: (workspaceId: string) =>
|
|
22
29
|
http<{ connections: TaskConnection[] }>(`${ws(workspaceId)}/task-sources/connections`),
|
|
@@ -15,6 +15,7 @@ import { recurringApi } from './api/recurring'
|
|
|
15
15
|
import { releaseHealthApi } from './api/releaseHealth'
|
|
16
16
|
import { reviewsApi } from './api/reviews'
|
|
17
17
|
import { slackApi } from './api/slack'
|
|
18
|
+
import { specApi } from './api/spec'
|
|
18
19
|
import { tasksApi } from './api/tasks'
|
|
19
20
|
import { workspacesApi } from './api/workspaces'
|
|
20
21
|
|
|
@@ -78,6 +79,7 @@ export function useApi() {
|
|
|
78
79
|
...documentsApi(ctx),
|
|
79
80
|
...tasksApi(ctx),
|
|
80
81
|
...reviewsApi(ctx),
|
|
82
|
+
...specApi(ctx),
|
|
81
83
|
...notificationsApi(ctx),
|
|
82
84
|
...presetsApi(ctx),
|
|
83
85
|
...releaseHealthApi(ctx),
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { reactive, ref } from 'vue'
|
|
3
|
+
import type { ServiceSpecView } from '~/types/spec'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Service-spec read state for the inspector's "View Requirements" window. The spec lives
|
|
8
|
+
* sharded in the service repo under `spec/`; the backend reassembles it from the repo's
|
|
9
|
+
* default branch and serves a {@link ServiceSpecView}. Read-only and fetched on demand
|
|
10
|
+
* (per service frame block), cached per block. Nothing is persisted client-side.
|
|
11
|
+
*/
|
|
12
|
+
export const useServiceSpecStore = defineStore('serviceSpec', () => {
|
|
13
|
+
const api = useApi()
|
|
14
|
+
const workspace = useWorkspaceStore()
|
|
15
|
+
|
|
16
|
+
/** The fetched view per block id (undefined = not yet fetched). */
|
|
17
|
+
const views = ref<Record<string, ServiceSpecView>>({})
|
|
18
|
+
/**
|
|
19
|
+
* In-flight loads keyed by block id — the SINGLE source of truth for "is this block
|
|
20
|
+
* loading". A reactive Map so `isLoading` (derived from `.has`) tracks set/delete, and it
|
|
21
|
+
* also coalesces overlapping loads onto one request: no separate loading-flag Set to keep
|
|
22
|
+
* in sync.
|
|
23
|
+
*/
|
|
24
|
+
const inFlight = reactive(new Map<string, Promise<void>>())
|
|
25
|
+
/** Block ids whose last fetch failed (network / unexpected error). */
|
|
26
|
+
const erroredByBlock = ref<Set<string>>(new Set())
|
|
27
|
+
|
|
28
|
+
function viewFor(blockId: string): ServiceSpecView | undefined {
|
|
29
|
+
return views.value[blockId]
|
|
30
|
+
}
|
|
31
|
+
function isLoading(blockId: string): boolean {
|
|
32
|
+
return inFlight.has(blockId)
|
|
33
|
+
}
|
|
34
|
+
function isErrored(blockId: string): boolean {
|
|
35
|
+
return erroredByBlock.value.has(blockId)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setErrored(key: string, on: boolean) {
|
|
39
|
+
const next = new Set(erroredByBlock.value)
|
|
40
|
+
if (on) next.add(key)
|
|
41
|
+
else next.delete(key)
|
|
42
|
+
erroredByBlock.value = next
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Fetch (and cache) the spec view for a service frame block. */
|
|
46
|
+
async function load(blockId: string) {
|
|
47
|
+
if (!workspace.workspaceId) return
|
|
48
|
+
const pending = inFlight.get(blockId)
|
|
49
|
+
if (pending) return pending
|
|
50
|
+
setErrored(blockId, false)
|
|
51
|
+
const promise = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const view = await api.getServiceSpec(workspace.requireId(), blockId)
|
|
54
|
+
views.value = { ...views.value, [blockId]: view }
|
|
55
|
+
} catch {
|
|
56
|
+
setErrored(blockId, true)
|
|
57
|
+
} finally {
|
|
58
|
+
inFlight.delete(blockId)
|
|
59
|
+
}
|
|
60
|
+
})()
|
|
61
|
+
inFlight.set(blockId, promise)
|
|
62
|
+
return promise
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
views,
|
|
67
|
+
viewFor,
|
|
68
|
+
isLoading,
|
|
69
|
+
isErrored,
|
|
70
|
+
load,
|
|
71
|
+
}
|
|
72
|
+
})
|
package/app/stores/tasks.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import type { SourceTask, TaskConnection,
|
|
2
|
+
import type { SourceTask, TaskConnection, TaskSourceState } from '~/types/domain'
|
|
3
3
|
import { useTasksStore } from '~/stores/tasks'
|
|
4
4
|
|
|
5
5
|
/** Minimal SourceTask factory — only the fields the read getters care about. */
|
|
@@ -23,13 +23,15 @@ function sourceTask(externalId: string, over: Partial<SourceTask> = {}): SourceT
|
|
|
23
23
|
}
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
const jiraDescriptor:
|
|
26
|
+
const jiraDescriptor: TaskSourceState = {
|
|
27
27
|
source: 'jira',
|
|
28
28
|
label: 'Jira',
|
|
29
29
|
icon: 'i-lucide-square-check',
|
|
30
30
|
credentialFields: [],
|
|
31
31
|
refLabel: 'Issue key or URL',
|
|
32
32
|
refPlaceholder: 'PROJ-123',
|
|
33
|
+
available: true,
|
|
34
|
+
enabled: true,
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
const jiraConnection: TaskConnection = { source: 'jira', label: 'acme', connectedAt: 0 }
|
package/app/stores/tasks.ts
CHANGED
|
@@ -4,8 +4,8 @@ import type {
|
|
|
4
4
|
SourceTask,
|
|
5
5
|
TaskConnection,
|
|
6
6
|
TaskSearchResult,
|
|
7
|
-
TaskSourceDescriptor,
|
|
8
7
|
TaskSourceKind,
|
|
8
|
+
TaskSourceState,
|
|
9
9
|
} from '~/types/domain'
|
|
10
10
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
11
11
|
|
|
@@ -27,9 +27,9 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
27
27
|
|
|
28
28
|
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
29
29
|
const available = ref<boolean | null>(null)
|
|
30
|
-
/** The configured sources
|
|
31
|
-
const sources = ref<
|
|
32
|
-
/** Live connections, one per connected source. */
|
|
30
|
+
/** The configured sources, each with its descriptor + per-workspace state (available + enabled). */
|
|
31
|
+
const sources = ref<TaskSourceState[]>([])
|
|
32
|
+
/** Live connections, one per connected (credentialed) source. */
|
|
33
33
|
const connections = ref<TaskConnection[]>([])
|
|
34
34
|
const tasks = ref<SourceTask[]>([])
|
|
35
35
|
const loading = ref(false)
|
|
@@ -40,7 +40,11 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
40
40
|
)
|
|
41
41
|
const anyConnected = computed(() => connections.value.length > 0)
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/** Sources offered for import: available (connected / App installed) AND enabled. */
|
|
44
|
+
const offeredSources = computed(() => sources.value.filter((s) => s.available && s.enabled))
|
|
45
|
+
const anyOffered = computed(() => offeredSources.value.length > 0)
|
|
46
|
+
|
|
47
|
+
function descriptorFor(source: TaskSourceKind): TaskSourceState | undefined {
|
|
44
48
|
return sources.value.find((s) => s.source === source)
|
|
45
49
|
}
|
|
46
50
|
|
|
@@ -104,6 +108,13 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
104
108
|
connections.value = connections.value.filter((c) => c.source !== source)
|
|
105
109
|
}
|
|
106
110
|
|
|
111
|
+
/** Enable or disable a source for the workspace (the per-workspace toggle). */
|
|
112
|
+
async function setEnabled(source: TaskSourceKind, enabled: boolean) {
|
|
113
|
+
await api.setTaskSourceEnabled(workspace.requireId(), source, enabled)
|
|
114
|
+
const i = sources.value.findIndex((s) => s.source === source)
|
|
115
|
+
if (i >= 0) sources.value[i] = { ...sources.value[i]!, enabled }
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
/** Load the imported issues for the workspace (across sources). */
|
|
108
119
|
async function loadTasks() {
|
|
109
120
|
tasks.value = await api.listTasks(workspace.requireId())
|
|
@@ -160,6 +171,8 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
160
171
|
loading,
|
|
161
172
|
connectedSources,
|
|
162
173
|
anyConnected,
|
|
174
|
+
offeredSources,
|
|
175
|
+
anyOffered,
|
|
163
176
|
descriptorFor,
|
|
164
177
|
connectionFor,
|
|
165
178
|
isConnected,
|
|
@@ -167,6 +180,7 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
167
180
|
probe,
|
|
168
181
|
connect,
|
|
169
182
|
disconnect,
|
|
183
|
+
setEnabled,
|
|
170
184
|
loadTasks,
|
|
171
185
|
importTask,
|
|
172
186
|
search,
|
package/app/stores/ui.ts
CHANGED
|
@@ -336,6 +336,10 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
336
336
|
function openClarityReview(blockId: string) {
|
|
337
337
|
resultView.value = { view: 'clarity-review', blockId, instanceId: null, stepIndex: null }
|
|
338
338
|
}
|
|
339
|
+
// Open the service-spec window for a service frame (the inspector's "View Requirements").
|
|
340
|
+
function openServiceSpec(blockId: string) {
|
|
341
|
+
resultView.value = { view: 'service-spec', blockId, instanceId: null, stepIndex: null }
|
|
342
|
+
}
|
|
339
343
|
function closeResultView() {
|
|
340
344
|
resultView.value = null
|
|
341
345
|
}
|
|
@@ -440,6 +444,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
440
444
|
closeOpenRouter,
|
|
441
445
|
openRequirementReview,
|
|
442
446
|
openClarityReview,
|
|
447
|
+
openServiceSpec,
|
|
443
448
|
closeRequirementReview,
|
|
444
449
|
openStepDetail,
|
|
445
450
|
closeStepDetail,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// The prescriptive, service-level SPECIFICATION read view. These shapes mirror the
|
|
2
|
+
// `@cat-factory/contracts` `spec.ts` wire schemas exactly (see the note in `domain.ts`),
|
|
3
|
+
// so the service-spec endpoint's payload drops straight into the store. The spec lives
|
|
4
|
+
// sharded in the service repo under `spec/`; the backend reassembles it from the repo's
|
|
5
|
+
// default branch and serves it as `ServiceSpecView` for the inspector's "View
|
|
6
|
+
// Requirements" window.
|
|
7
|
+
|
|
8
|
+
export type RequirementPriority = 'must' | 'should' | 'could'
|
|
9
|
+
export type RequirementKind = 'functional' | 'nonfunctional' | 'constraint'
|
|
10
|
+
|
|
11
|
+
/** One acceptance criterion in Given/When/Then form — the seed for a Gherkin scenario. */
|
|
12
|
+
export interface AcceptanceCriterion {
|
|
13
|
+
id: string
|
|
14
|
+
given: string
|
|
15
|
+
when: string
|
|
16
|
+
/** The Gherkin "Then" clause (named `outcome` so the object is never thenable). */
|
|
17
|
+
outcome: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A single prescriptive requirement, traceable to the board task(s) it came from. */
|
|
21
|
+
export interface RequirementItem {
|
|
22
|
+
id: string
|
|
23
|
+
title: string
|
|
24
|
+
statement: string
|
|
25
|
+
kind: RequirementKind
|
|
26
|
+
priority: RequirementPriority
|
|
27
|
+
sourceBlockIds?: string[]
|
|
28
|
+
acceptance?: AcceptanceCriterion[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A domain rule / invariant scoped to the feature group it governs. */
|
|
32
|
+
export interface DomainRule {
|
|
33
|
+
id: string
|
|
34
|
+
rule: string
|
|
35
|
+
rationale?: string
|
|
36
|
+
sourceBlockIds?: string[]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A feature / logical group: related requirements plus the domain rules scoped to them. */
|
|
40
|
+
export interface RequirementGroup {
|
|
41
|
+
name: string
|
|
42
|
+
summary?: string
|
|
43
|
+
requirements?: RequirementItem[]
|
|
44
|
+
rules?: DomainRule[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A module (domain) — the top level of the taxonomy, holding feature groups. */
|
|
48
|
+
export interface SpecModule {
|
|
49
|
+
name: string
|
|
50
|
+
summary?: string
|
|
51
|
+
groups?: RequirementGroup[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The unified prescriptive specification document for one service. */
|
|
55
|
+
export interface SpecDoc {
|
|
56
|
+
service: string
|
|
57
|
+
summary?: string
|
|
58
|
+
modules?: SpecModule[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A rendered Gherkin feature file read back from the repo. */
|
|
62
|
+
export interface SpecFeatureFile {
|
|
63
|
+
module: string
|
|
64
|
+
group: string
|
|
65
|
+
path: string
|
|
66
|
+
content: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The service-spec view: the reassembled tree + its Gherkin files (empty when none). */
|
|
70
|
+
export interface ServiceSpecView {
|
|
71
|
+
present: boolean
|
|
72
|
+
spec: SpecDoc | null
|
|
73
|
+
features: SpecFeatureFile[]
|
|
74
|
+
}
|
package/app/types/tasks.ts
CHANGED
|
@@ -26,6 +26,17 @@ export interface TaskSourceDescriptor {
|
|
|
26
26
|
searchable?: boolean
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* A source's descriptor plus the workspace's live state: whether it's usable now
|
|
31
|
+
* (`available` — a credentialed source is connected; GitHub Issues' App is
|
|
32
|
+
* installed) and whether the workspace offers it (`enabled`, the per-workspace
|
|
33
|
+
* toggle, default true). `available && enabled` is what makes a source offered.
|
|
34
|
+
*/
|
|
35
|
+
export interface TaskSourceState extends TaskSourceDescriptor {
|
|
36
|
+
available: boolean
|
|
37
|
+
enabled: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
/** A workspace's connection to a task source (never carries credentials). */
|
|
30
41
|
export interface TaskConnection {
|
|
31
42
|
source: TaskSourceKind
|
package/nuxt.config.ts
CHANGED
|
@@ -15,6 +15,15 @@ export default defineNuxtConfig({
|
|
|
15
15
|
// Render as a pure client-side SPA that talks to the cat-factory backend.
|
|
16
16
|
ssr: false,
|
|
17
17
|
|
|
18
|
+
// The board is a single dark-themed surface (neutral is mapped to `slate` and
|
|
19
|
+
// every component is hand-styled in slate). Pin Nuxt UI's color mode to dark so
|
|
20
|
+
// its own chrome (modals, inputs, selects, dropdowns) matches instead of
|
|
21
|
+
// following the visitor's system preference and rendering light/white overlays.
|
|
22
|
+
colorMode: {
|
|
23
|
+
preference: 'dark',
|
|
24
|
+
fallback: 'dark',
|
|
25
|
+
},
|
|
26
|
+
|
|
18
27
|
runtimeConfig: {
|
|
19
28
|
public: {
|
|
20
29
|
// Base URL of the cat-factory worker API. Defaults to the local wrangler
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|