@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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/app/app.config.ts +8 -0
- package/app/app.vue +11 -0
- package/app/assets/css/main.css +100 -0
- package/app/components/auth/AuthGate.vue +24 -0
- package/app/components/auth/LoginScreen.vue +18 -0
- package/app/components/auth/UserMenu.vue +39 -0
- package/app/components/board/AgentFailureCard.vue +97 -0
- package/app/components/board/AgentStopButton.vue +61 -0
- package/app/components/board/BoardCanvas.vue +146 -0
- package/app/components/board/TaskDependencyEdges.vue +132 -0
- package/app/components/board/nodes/AgentChip.vue +59 -0
- package/app/components/board/nodes/BlockNode.vue +347 -0
- package/app/components/board/nodes/DecisionBadge.vue +21 -0
- package/app/components/board/nodes/DraggableTask.vue +69 -0
- package/app/components/board/nodes/ModuleFrame.vue +70 -0
- package/app/components/board/nodes/TaskCard.vue +237 -0
- package/app/components/bootstrap/BootstrapModal.vue +665 -0
- package/app/components/documents/DocumentImportModal.vue +161 -0
- package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
- package/app/components/documents/SpawnPreviewModal.vue +161 -0
- package/app/components/documents/TaskContextDocs.vue +83 -0
- package/app/components/focus/BlockFocusView.vue +161 -0
- package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
- package/app/components/github/GitHubConnect.vue +183 -0
- package/app/components/github/GitHubPanel.vue +584 -0
- package/app/components/layout/BoardSwitcher.vue +202 -0
- package/app/components/layout/BoardToolbar.vue +109 -0
- package/app/components/layout/SideBar.vue +193 -0
- package/app/components/layout/SpendWarningBanner.vue +107 -0
- package/app/components/palettes/AgentPalette.vue +33 -0
- package/app/components/palettes/BlockPalette.vue +41 -0
- package/app/components/palettes/PipelinePalette.vue +74 -0
- package/app/components/panels/DecisionModal.vue +71 -0
- package/app/components/panels/InspectorPanel.vue +296 -0
- package/app/components/panels/inspector/ContainerSummary.vue +74 -0
- package/app/components/panels/inspector/TaskDependencies.vue +70 -0
- package/app/components/panels/inspector/TaskExecution.vue +175 -0
- package/app/components/panels/inspector/TaskModelSettings.vue +128 -0
- package/app/components/panels/inspector/TaskStructure.vue +139 -0
- package/app/components/pipeline/PipelineBuilder.vue +227 -0
- package/app/components/pipeline/PipelineProgress.vue +246 -0
- package/app/components/requirements/RequirementReviewModal.vue +328 -0
- package/app/components/scenarios/FeatureScenarios.vue +162 -0
- package/app/components/scenarios/ScenarioCard.vue +109 -0
- package/app/components/tasks/TaskContextIssues.vue +88 -0
- package/app/components/tasks/TaskImportModal.vue +140 -0
- package/app/components/tasks/TaskSourceConnectModal.vue +122 -0
- package/app/composables/useApi.ts +535 -0
- package/app/composables/useBlockDrag.ts +75 -0
- package/app/composables/useBlockQueries.ts +136 -0
- package/app/composables/useBoardFlow.ts +11 -0
- package/app/composables/useDepLabels.ts +26 -0
- package/app/composables/useSemanticZoom.ts +16 -0
- package/app/composables/useWorkspaceStream.ts +125 -0
- package/app/docs/architecture.md +31 -0
- package/app/pages/index.vue +80 -0
- package/app/stores/accounts.ts +64 -0
- package/app/stores/agentRuns.ts +117 -0
- package/app/stores/agents.ts +40 -0
- package/app/stores/auth.ts +97 -0
- package/app/stores/board.spec.ts +197 -0
- package/app/stores/board.ts +147 -0
- package/app/stores/bootstrap.ts +97 -0
- package/app/stores/documents.ts +165 -0
- package/app/stores/execution.ts +115 -0
- package/app/stores/fragmentLibrary.ts +147 -0
- package/app/stores/fragments.ts +40 -0
- package/app/stores/github.ts +291 -0
- package/app/stores/models.ts +48 -0
- package/app/stores/pipelines.ts +77 -0
- package/app/stores/requirements.ts +133 -0
- package/app/stores/scenarios.spec.ts +82 -0
- package/app/stores/scenarios.ts +196 -0
- package/app/stores/tasks.spec.ts +71 -0
- package/app/stores/tasks.ts +149 -0
- package/app/stores/ui.ts +204 -0
- package/app/stores/workspace.ts +201 -0
- package/app/types/accounts.ts +38 -0
- package/app/types/bootstrap.ts +83 -0
- package/app/types/documents.ts +92 -0
- package/app/types/domain.ts +216 -0
- package/app/types/execution.ts +110 -0
- package/app/types/fragments.ts +72 -0
- package/app/types/github.ts +153 -0
- package/app/types/models.ts +48 -0
- package/app/types/requirements.ts +38 -0
- package/app/types/scenarios.ts +36 -0
- package/app/types/tasks.ts +67 -0
- package/app/utils/catalog.spec.ts +82 -0
- package/app/utils/catalog.ts +185 -0
- package/app/utils/dnd.ts +29 -0
- package/nuxt.config.ts +43 -0
- package/package.json +43 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/* eslint-disable unicorn/no-thenable -- `then` is the Gherkin clause name on plain
|
|
2
|
+
scenario data objects (a string[]), never a thenable callback; these objects
|
|
3
|
+
are never awaited. */
|
|
4
|
+
import { defineStore } from 'pinia'
|
|
5
|
+
import { ref, computed } from 'vue'
|
|
6
|
+
import { uid } from '~/utils/catalog'
|
|
7
|
+
import type { AcceptanceScenario, Block } from '~/types/domain'
|
|
8
|
+
|
|
9
|
+
/** Context the acceptance agent draws on when drafting scenarios for a feature. */
|
|
10
|
+
export interface ScenarioGenerationContext {
|
|
11
|
+
/** The block's free-text intent. */
|
|
12
|
+
description?: string
|
|
13
|
+
/** Titles/excerpts of linked requirement docs (PRDs), for traceable scenarios. */
|
|
14
|
+
requirements?: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Normalise a feature/title into a comparable key (case- and space-insensitive). */
|
|
18
|
+
function normalize(value: string): string {
|
|
19
|
+
return value.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Draft the standard set of acceptance scenarios for a feature: the happy path,
|
|
24
|
+
* an error path and an input-validation path. This mirrors what the `acceptance`
|
|
25
|
+
* agent does from requirements — deterministic here so the prototype has
|
|
26
|
+
* something concrete and editable to show. The feature name and any linked
|
|
27
|
+
* requirements are folded into the Given/When/Then so the output is specific.
|
|
28
|
+
*/
|
|
29
|
+
function draftScenarios(
|
|
30
|
+
feature: string,
|
|
31
|
+
context: ScenarioGenerationContext = {},
|
|
32
|
+
): Omit<AcceptanceScenario, 'id' | 'createdAt'>[] {
|
|
33
|
+
const name = feature.trim()
|
|
34
|
+
const reqGiven = context.requirements?.length
|
|
35
|
+
? [`the requirements for "${name}" (${context.requirements.join('; ')})`]
|
|
36
|
+
: []
|
|
37
|
+
const base = ['a user on the application', ...reqGiven]
|
|
38
|
+
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
feature: name,
|
|
42
|
+
title: `${name}: happy path`,
|
|
43
|
+
given: base,
|
|
44
|
+
when: [`the user completes the "${name}" flow with valid input`],
|
|
45
|
+
then: [`the action succeeds`, `the expected result for "${name}" is shown`],
|
|
46
|
+
status: 'draft',
|
|
47
|
+
source: 'generated',
|
|
48
|
+
hasPlaywrightTest: false,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
feature: name,
|
|
52
|
+
title: `${name}: invalid input is rejected`,
|
|
53
|
+
given: base,
|
|
54
|
+
when: [`the user attempts the "${name}" flow with invalid input`],
|
|
55
|
+
then: [`the action is rejected`, `a clear error message is shown`],
|
|
56
|
+
status: 'draft',
|
|
57
|
+
source: 'generated',
|
|
58
|
+
hasPlaywrightTest: false,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
feature: name,
|
|
62
|
+
title: `${name}: required fields are validated`,
|
|
63
|
+
given: base,
|
|
64
|
+
when: [`the user submits the "${name}" flow with required fields missing`],
|
|
65
|
+
then: [`submission is blocked`, `each missing field is flagged`],
|
|
66
|
+
status: 'draft',
|
|
67
|
+
source: 'generated',
|
|
68
|
+
hasPlaywrightTest: false,
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The acceptance-scenario catalog. Feature-scoped Given/When/Then scenarios that
|
|
75
|
+
* the `acceptance` agent drafts from requirements and the `playwright` agent
|
|
76
|
+
* turns into e2e tests. Authored and refined client-side (persisted locally),
|
|
77
|
+
* this is the data the feature's scenario viewer renders.
|
|
78
|
+
*/
|
|
79
|
+
export const useScenariosStore = defineStore(
|
|
80
|
+
'scenarios',
|
|
81
|
+
() => {
|
|
82
|
+
const scenarios = ref<AcceptanceScenario[]>([])
|
|
83
|
+
|
|
84
|
+
/** Scenarios for a single feature, oldest first. */
|
|
85
|
+
function scenariosForFeature(feature: string): AcceptanceScenario[] {
|
|
86
|
+
const key = normalize(feature)
|
|
87
|
+
return scenarios.value
|
|
88
|
+
.filter((s) => normalize(s.feature) === key)
|
|
89
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Scenarios across all of a block's features (the "current set" for a task). */
|
|
93
|
+
function scenariosForBlock(block: Pick<Block, 'features'>): AcceptanceScenario[] {
|
|
94
|
+
const features = (block.features ?? []).map(normalize)
|
|
95
|
+
if (!features.length) return []
|
|
96
|
+
const set = new Set(features)
|
|
97
|
+
return scenarios.value
|
|
98
|
+
.filter((s) => set.has(normalize(s.feature)))
|
|
99
|
+
.sort((a, b) => a.createdAt - b.createdAt)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when a feature already has at least one scenario. */
|
|
103
|
+
function hasScenarios(feature: string): boolean {
|
|
104
|
+
const key = normalize(feature)
|
|
105
|
+
return scenarios.value.some((s) => normalize(s.feature) === key)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function addScenario(input: {
|
|
109
|
+
feature: string
|
|
110
|
+
title?: string
|
|
111
|
+
given?: string[]
|
|
112
|
+
when?: string[]
|
|
113
|
+
then?: string[]
|
|
114
|
+
source?: AcceptanceScenario['source']
|
|
115
|
+
}): AcceptanceScenario {
|
|
116
|
+
const scenario: AcceptanceScenario = {
|
|
117
|
+
id: uid('scn'),
|
|
118
|
+
feature: input.feature.trim(),
|
|
119
|
+
title: input.title?.trim() || 'New scenario',
|
|
120
|
+
given: input.given ?? [],
|
|
121
|
+
when: input.when ?? [],
|
|
122
|
+
then: input.then ?? [],
|
|
123
|
+
status: 'draft',
|
|
124
|
+
source: input.source ?? 'manual',
|
|
125
|
+
hasPlaywrightTest: false,
|
|
126
|
+
createdAt: Date.now(),
|
|
127
|
+
}
|
|
128
|
+
scenarios.value.push(scenario)
|
|
129
|
+
return scenario
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function updateScenario(id: string, patch: Partial<AcceptanceScenario>) {
|
|
133
|
+
const scenario = scenarios.value.find((s) => s.id === id)
|
|
134
|
+
if (!scenario) return
|
|
135
|
+
Object.assign(scenario, patch)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function removeScenario(id: string) {
|
|
139
|
+
scenarios.value = scenarios.value.filter((s) => s.id !== id)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Draft scenarios for a feature from its requirements. Additive: titles that
|
|
144
|
+
* already exist for the feature are skipped, so re-running only fills gaps and
|
|
145
|
+
* never clobbers edits. Returns the scenarios actually created.
|
|
146
|
+
*/
|
|
147
|
+
function generateForFeature(
|
|
148
|
+
feature: string,
|
|
149
|
+
context: ScenarioGenerationContext = {},
|
|
150
|
+
): AcceptanceScenario[] {
|
|
151
|
+
const existing = new Set(scenariosForFeature(feature).map((s) => normalize(s.title)))
|
|
152
|
+
const created: AcceptanceScenario[] = []
|
|
153
|
+
for (const draft of draftScenarios(feature, context)) {
|
|
154
|
+
if (existing.has(normalize(draft.title))) continue
|
|
155
|
+
created.push(addScenario({ ...draft, source: 'generated' }))
|
|
156
|
+
}
|
|
157
|
+
return created
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* "Generate Playwright tests" for a feature. Mirrors the `playwright` agent's
|
|
162
|
+
* idempotent contract: only scenarios that don't yet have a test get one, so
|
|
163
|
+
* existing committed tests are never regenerated. Returns the scenarios for
|
|
164
|
+
* which a new test was created.
|
|
165
|
+
*/
|
|
166
|
+
function generatePlaywrightTests(feature: string): AcceptanceScenario[] {
|
|
167
|
+
const created: AcceptanceScenario[] = []
|
|
168
|
+
for (const scenario of scenariosForFeature(feature)) {
|
|
169
|
+
if (scenario.hasPlaywrightTest) continue
|
|
170
|
+
scenario.hasPlaywrightTest = true
|
|
171
|
+
created.push(scenario)
|
|
172
|
+
}
|
|
173
|
+
return created
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Count of scenarios still missing a Playwright test, for a feature. */
|
|
177
|
+
const untested = computed(
|
|
178
|
+
() => (feature: string) =>
|
|
179
|
+
scenariosForFeature(feature).filter((s) => !s.hasPlaywrightTest).length,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
scenarios,
|
|
184
|
+
scenariosForFeature,
|
|
185
|
+
scenariosForBlock,
|
|
186
|
+
hasScenarios,
|
|
187
|
+
untested,
|
|
188
|
+
addScenario,
|
|
189
|
+
updateScenario,
|
|
190
|
+
removeScenario,
|
|
191
|
+
generateForFeature,
|
|
192
|
+
generatePlaywrightTests,
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{ persist: true },
|
|
196
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import type { SourceTask, TaskConnection, TaskSourceDescriptor } from '~/types/domain'
|
|
3
|
+
import { useTasksStore } from '~/stores/tasks'
|
|
4
|
+
|
|
5
|
+
/** Minimal SourceTask factory — only the fields the read getters care about. */
|
|
6
|
+
function sourceTask(externalId: string, over: Partial<SourceTask> = {}): SourceTask {
|
|
7
|
+
return {
|
|
8
|
+
source: 'jira',
|
|
9
|
+
externalId,
|
|
10
|
+
title: externalId,
|
|
11
|
+
url: `https://acme.atlassian.net/browse/${externalId}`,
|
|
12
|
+
status: 'To Do',
|
|
13
|
+
type: 'Task',
|
|
14
|
+
assignee: null,
|
|
15
|
+
priority: null,
|
|
16
|
+
labels: [],
|
|
17
|
+
description: '',
|
|
18
|
+
comments: [],
|
|
19
|
+
excerpt: '',
|
|
20
|
+
linkedBlockId: null,
|
|
21
|
+
syncedAt: 0,
|
|
22
|
+
...over,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const jiraDescriptor: TaskSourceDescriptor = {
|
|
27
|
+
source: 'jira',
|
|
28
|
+
label: 'Jira',
|
|
29
|
+
icon: 'i-lucide-square-check',
|
|
30
|
+
credentialFields: [],
|
|
31
|
+
refLabel: 'Issue key or URL',
|
|
32
|
+
refPlaceholder: 'PROJ-123',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const jiraConnection: TaskConnection = { source: 'jira', label: 'acme', connectedAt: 0 }
|
|
36
|
+
|
|
37
|
+
describe('tasks store read getters', () => {
|
|
38
|
+
let store: ReturnType<typeof useTasksStore>
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
store = useTasksStore()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('tasksForBlock returns only issues linked to the block', () => {
|
|
44
|
+
store.tasks = [
|
|
45
|
+
sourceTask('PROJ-1', { linkedBlockId: 'b1' }),
|
|
46
|
+
sourceTask('PROJ-2', { linkedBlockId: 'b2' }),
|
|
47
|
+
sourceTask('PROJ-3', { linkedBlockId: null }),
|
|
48
|
+
]
|
|
49
|
+
expect(store.tasksForBlock('b1').map((t) => t.externalId)).toEqual(['PROJ-1'])
|
|
50
|
+
expect(store.tasksForBlock('bX')).toEqual([])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('isConnected / connectedSources / anyConnected reflect connections', () => {
|
|
54
|
+
store.sources = [jiraDescriptor]
|
|
55
|
+
expect(store.anyConnected).toBe(false)
|
|
56
|
+
expect(store.isConnected('jira')).toBe(false)
|
|
57
|
+
expect(store.connectedSources).toEqual([])
|
|
58
|
+
|
|
59
|
+
store.connections = [jiraConnection]
|
|
60
|
+
expect(store.anyConnected).toBe(true)
|
|
61
|
+
expect(store.isConnected('jira')).toBe(true)
|
|
62
|
+
expect(store.connectedSources.map((s) => s.source)).toEqual(['jira'])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('descriptorFor / connectionFor resolve by source', () => {
|
|
66
|
+
store.sources = [jiraDescriptor]
|
|
67
|
+
store.connections = [jiraConnection]
|
|
68
|
+
expect(store.descriptorFor('jira')?.label).toBe('Jira')
|
|
69
|
+
expect(store.connectionFor('jira')?.label).toBe('acme')
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type {
|
|
4
|
+
SourceTask,
|
|
5
|
+
TaskConnection,
|
|
6
|
+
TaskSourceDescriptor,
|
|
7
|
+
TaskSourceKind,
|
|
8
|
+
} from '~/types/domain'
|
|
9
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Task-source integration state: the trackers the backend offers (and their
|
|
13
|
+
* connect metadata), the workspace's per-source connections, and the issues it
|
|
14
|
+
* has imported — plus the actions that connect/import/link against the backend.
|
|
15
|
+
* `available` mirrors the backend's opt-in gate: a 503 from the source probe
|
|
16
|
+
* means the integration is off, and the UI hides its entry points (just as the
|
|
17
|
+
* documents store does). The abstraction is source-agnostic; every action is
|
|
18
|
+
* keyed by a `TaskSourceKind`. Per-workspace; nothing is persisted client-side.
|
|
19
|
+
*
|
|
20
|
+
* Unlike documents there is no plan/spawn — an issue is linked to a block for
|
|
21
|
+
* agent context, never expanded into board structure.
|
|
22
|
+
*/
|
|
23
|
+
export const useTasksStore = defineStore('tasks', () => {
|
|
24
|
+
const api = useApi()
|
|
25
|
+
const workspace = useWorkspaceStore()
|
|
26
|
+
|
|
27
|
+
/** null = unknown (not probed yet), true/false = integration on/off. */
|
|
28
|
+
const available = ref<boolean | null>(null)
|
|
29
|
+
/** The configured sources and their connect/import descriptors. */
|
|
30
|
+
const sources = ref<TaskSourceDescriptor[]>([])
|
|
31
|
+
/** Live connections, one per connected source. */
|
|
32
|
+
const connections = ref<TaskConnection[]>([])
|
|
33
|
+
const tasks = ref<SourceTask[]>([])
|
|
34
|
+
const loading = ref(false)
|
|
35
|
+
|
|
36
|
+
/** Sources the workspace currently has a live connection to. */
|
|
37
|
+
const connectedSources = computed(() =>
|
|
38
|
+
sources.value.filter((s) => connections.value.some((c) => c.source === s.source)),
|
|
39
|
+
)
|
|
40
|
+
const anyConnected = computed(() => connections.value.length > 0)
|
|
41
|
+
|
|
42
|
+
function descriptorFor(source: TaskSourceKind): TaskSourceDescriptor | undefined {
|
|
43
|
+
return sources.value.find((s) => s.source === source)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function connectionFor(source: TaskSourceKind): TaskConnection | undefined {
|
|
47
|
+
return connections.value.find((c) => c.source === source)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isConnected(source: TaskSourceKind): boolean {
|
|
51
|
+
return connectionFor(source) !== undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Imported issues currently attached to a given block. */
|
|
55
|
+
function tasksForBlock(blockId: string): SourceTask[] {
|
|
56
|
+
return tasks.value.filter((t) => t.linkedBlockId === blockId)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Merge an issue returned by the backend into the local cache. */
|
|
60
|
+
function upsertTask(task: SourceTask) {
|
|
61
|
+
const i = tasks.value.findIndex(
|
|
62
|
+
(t) => t.source === task.source && t.externalId === task.externalId,
|
|
63
|
+
)
|
|
64
|
+
if (i >= 0) tasks.value[i] = task
|
|
65
|
+
else tasks.value.unshift(task)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function upsertConnection(conn: TaskConnection) {
|
|
69
|
+
const i = connections.value.findIndex((c) => c.source === conn.source)
|
|
70
|
+
if (i >= 0) connections.value[i] = conn
|
|
71
|
+
else connections.value.push(conn)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Probe the integration: resolves `available`, the sources and connections. */
|
|
75
|
+
async function probe() {
|
|
76
|
+
if (!workspace.workspaceId) return
|
|
77
|
+
try {
|
|
78
|
+
const [{ sources: srcs }, { connections: conns }] = await Promise.all([
|
|
79
|
+
api.listTaskSources(workspace.requireId()),
|
|
80
|
+
api.listTaskConnections(workspace.requireId()),
|
|
81
|
+
])
|
|
82
|
+
available.value = true
|
|
83
|
+
sources.value = srcs
|
|
84
|
+
connections.value = conns
|
|
85
|
+
} catch {
|
|
86
|
+
// 503 (integration disabled) or any error → hide the UI entry points.
|
|
87
|
+
available.value = false
|
|
88
|
+
sources.value = []
|
|
89
|
+
connections.value = []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Connect the workspace to a source with its credential bag. */
|
|
94
|
+
async function connect(source: TaskSourceKind, credentials: Record<string, string>) {
|
|
95
|
+
const conn = await api.connectTaskSource(workspace.requireId(), source, credentials)
|
|
96
|
+
upsertConnection(conn)
|
|
97
|
+
available.value = true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Disconnect the workspace from a source. */
|
|
101
|
+
async function disconnect(source: TaskSourceKind) {
|
|
102
|
+
await api.disconnectTaskSource(workspace.requireId(), source)
|
|
103
|
+
connections.value = connections.value.filter((c) => c.source !== source)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Load the imported issues for the workspace (across sources). */
|
|
107
|
+
async function loadTasks() {
|
|
108
|
+
tasks.value = await api.listTasks(workspace.requireId())
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Import (fetch + persist) an issue by key or URL from a source. */
|
|
112
|
+
async function importTask(source: TaskSourceKind, ref: string): Promise<SourceTask> {
|
|
113
|
+
loading.value = true
|
|
114
|
+
try {
|
|
115
|
+
const task = await api.importTask(workspace.requireId(), source, { ref })
|
|
116
|
+
upsertTask(task)
|
|
117
|
+
return task
|
|
118
|
+
} finally {
|
|
119
|
+
loading.value = false
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Attach an imported issue to a block as agent context. */
|
|
124
|
+
async function linkToBlock(blockId: string, source: TaskSourceKind, externalId: string) {
|
|
125
|
+
const task = await api.linkTask(workspace.requireId(), { source, externalId, blockId })
|
|
126
|
+
upsertTask(task)
|
|
127
|
+
return task
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
available,
|
|
132
|
+
sources,
|
|
133
|
+
connections,
|
|
134
|
+
tasks,
|
|
135
|
+
loading,
|
|
136
|
+
connectedSources,
|
|
137
|
+
anyConnected,
|
|
138
|
+
descriptorFor,
|
|
139
|
+
connectionFor,
|
|
140
|
+
isConnected,
|
|
141
|
+
tasksForBlock,
|
|
142
|
+
probe,
|
|
143
|
+
connect,
|
|
144
|
+
disconnect,
|
|
145
|
+
loadTasks,
|
|
146
|
+
importTask,
|
|
147
|
+
linkToBlock,
|
|
148
|
+
}
|
|
149
|
+
})
|
package/app/stores/ui.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { ref, computed } from 'vue'
|
|
3
|
+
import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/** Transient UI state: selection, panels, zoom level. */
|
|
6
|
+
export const useUiStore = defineStore('ui', () => {
|
|
7
|
+
const selectedBlockId = ref<string | null>(null)
|
|
8
|
+
const focusBlockId = ref<string | null>(null)
|
|
9
|
+
const builderOpen = ref(false)
|
|
10
|
+
const decisionContext = ref<{ instanceId: string; decisionId: string } | null>(null)
|
|
11
|
+
|
|
12
|
+
// Document-source integration modals, keyed by source. `documentImport` and
|
|
13
|
+
// `spawnPreview` carry an optional target frame, so structure spawned from a
|
|
14
|
+
// frame's inspector lands inside that frame rather than creating new top-level
|
|
15
|
+
// frames. `documentConnect` carries the source whose connect form to show;
|
|
16
|
+
// `documentImport`'s source may be null to let the modal pick a connected one.
|
|
17
|
+
const documentConnect = ref<{ source: DocumentSourceKind } | null>(null)
|
|
18
|
+
const documentImport = ref<{
|
|
19
|
+
source: DocumentSourceKind | null
|
|
20
|
+
targetFrameId: string | null
|
|
21
|
+
} | null>(null)
|
|
22
|
+
const spawnPreview = ref<{
|
|
23
|
+
source: DocumentSourceKind
|
|
24
|
+
externalId: string
|
|
25
|
+
targetFrameId: string | null
|
|
26
|
+
} | null>(null)
|
|
27
|
+
|
|
28
|
+
// Task-source integration modals, keyed by source. `taskConnect` carries the
|
|
29
|
+
// source whose connect form to show; `taskImport`'s source may be null to let
|
|
30
|
+
// the modal pick a connected one (there is no spawn target — issues are linked
|
|
31
|
+
// to a block for context, not expanded into structure).
|
|
32
|
+
const taskConnect = ref<{ source: TaskSourceKind } | null>(null)
|
|
33
|
+
const taskImport = ref<{ source: TaskSourceKind | null } | null>(null)
|
|
34
|
+
|
|
35
|
+
// Repo-bootstrap modal (manage reference architectures + launch a bootstrap).
|
|
36
|
+
const bootstrapOpen = ref(false)
|
|
37
|
+
|
|
38
|
+
// GitHub integration panel (connection management + repo/PR/issue browsing).
|
|
39
|
+
const githubOpen = ref(false)
|
|
40
|
+
|
|
41
|
+
// Prompt-fragment library panel (manage the board's best-practice catalog +
|
|
42
|
+
// linked guideline repos; ADR 0006).
|
|
43
|
+
const fragmentLibraryOpen = ref(false)
|
|
44
|
+
|
|
45
|
+
// Requirements-review panel: the block whose requirements review (questions /
|
|
46
|
+
// gaps / clarifications) to show, or null when closed.
|
|
47
|
+
const requirementReviewBlockId = ref<string | null>(null)
|
|
48
|
+
|
|
49
|
+
/** Current canvas zoom (driven by Vue Flow viewport). */
|
|
50
|
+
const zoom = ref(1)
|
|
51
|
+
|
|
52
|
+
const lod = computed<LodLevel>(() => {
|
|
53
|
+
if (zoom.value < 0.6) return 'far'
|
|
54
|
+
if (zoom.value < 1.2) return 'mid'
|
|
55
|
+
return 'close'
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
/** Frames the user has manually expanded to reveal their tasks. */
|
|
59
|
+
const expandedFrames = ref<Set<string>>(new Set())
|
|
60
|
+
|
|
61
|
+
function toggleFrame(id: string) {
|
|
62
|
+
const next = new Set(expandedFrames.value)
|
|
63
|
+
if (next.has(id)) next.delete(id)
|
|
64
|
+
else next.add(id)
|
|
65
|
+
expandedFrames.value = next
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function expandFrame(id: string) {
|
|
69
|
+
if (expandedFrames.value.has(id)) return
|
|
70
|
+
expandedFrames.value = new Set(expandedFrames.value).add(id)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A frame shows its tasks when manually expanded OR when zoomed in close. */
|
|
74
|
+
function isFrameExpanded(id: string) {
|
|
75
|
+
return expandedFrames.value.has(id) || lod.value === 'close'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function select(id: string | null) {
|
|
79
|
+
selectedBlockId.value = id
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function focus(id: string | null) {
|
|
83
|
+
focusBlockId.value = id
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function openBuilder() {
|
|
87
|
+
builderOpen.value = true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function openDecision(instanceId: string, decisionId: string) {
|
|
91
|
+
decisionContext.value = { instanceId, decisionId }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function closeDecision() {
|
|
95
|
+
decisionContext.value = null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function openDocumentConnect(source: DocumentSourceKind) {
|
|
99
|
+
documentConnect.value = { source }
|
|
100
|
+
}
|
|
101
|
+
function closeDocumentConnect() {
|
|
102
|
+
documentConnect.value = null
|
|
103
|
+
}
|
|
104
|
+
function openDocumentImport(
|
|
105
|
+
targetFrameId: string | null = null,
|
|
106
|
+
source: DocumentSourceKind | null = null,
|
|
107
|
+
) {
|
|
108
|
+
documentImport.value = { source, targetFrameId }
|
|
109
|
+
}
|
|
110
|
+
function closeDocumentImport() {
|
|
111
|
+
documentImport.value = null
|
|
112
|
+
}
|
|
113
|
+
function openSpawnPreview(
|
|
114
|
+
source: DocumentSourceKind,
|
|
115
|
+
externalId: string,
|
|
116
|
+
targetFrameId: string | null = null,
|
|
117
|
+
) {
|
|
118
|
+
spawnPreview.value = { source, externalId, targetFrameId }
|
|
119
|
+
}
|
|
120
|
+
function closeSpawnPreview() {
|
|
121
|
+
spawnPreview.value = null
|
|
122
|
+
}
|
|
123
|
+
function openTaskConnect(source: TaskSourceKind) {
|
|
124
|
+
taskConnect.value = { source }
|
|
125
|
+
}
|
|
126
|
+
function closeTaskConnect() {
|
|
127
|
+
taskConnect.value = null
|
|
128
|
+
}
|
|
129
|
+
function openTaskImport(source: TaskSourceKind | null = null) {
|
|
130
|
+
taskImport.value = { source }
|
|
131
|
+
}
|
|
132
|
+
function closeTaskImport() {
|
|
133
|
+
taskImport.value = null
|
|
134
|
+
}
|
|
135
|
+
function openBootstrap() {
|
|
136
|
+
bootstrapOpen.value = true
|
|
137
|
+
}
|
|
138
|
+
function closeBootstrap() {
|
|
139
|
+
bootstrapOpen.value = false
|
|
140
|
+
}
|
|
141
|
+
function openGitHub() {
|
|
142
|
+
githubOpen.value = true
|
|
143
|
+
}
|
|
144
|
+
function closeGitHub() {
|
|
145
|
+
githubOpen.value = false
|
|
146
|
+
}
|
|
147
|
+
function openFragmentLibrary() {
|
|
148
|
+
fragmentLibraryOpen.value = true
|
|
149
|
+
}
|
|
150
|
+
function closeFragmentLibrary() {
|
|
151
|
+
fragmentLibraryOpen.value = false
|
|
152
|
+
}
|
|
153
|
+
function openRequirementReview(blockId: string) {
|
|
154
|
+
requirementReviewBlockId.value = blockId
|
|
155
|
+
}
|
|
156
|
+
function closeRequirementReview() {
|
|
157
|
+
requirementReviewBlockId.value = null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
selectedBlockId,
|
|
162
|
+
focusBlockId,
|
|
163
|
+
builderOpen,
|
|
164
|
+
decisionContext,
|
|
165
|
+
documentConnect,
|
|
166
|
+
documentImport,
|
|
167
|
+
spawnPreview,
|
|
168
|
+
taskConnect,
|
|
169
|
+
taskImport,
|
|
170
|
+
bootstrapOpen,
|
|
171
|
+
githubOpen,
|
|
172
|
+
fragmentLibraryOpen,
|
|
173
|
+
requirementReviewBlockId,
|
|
174
|
+
zoom,
|
|
175
|
+
lod,
|
|
176
|
+
expandedFrames,
|
|
177
|
+
toggleFrame,
|
|
178
|
+
expandFrame,
|
|
179
|
+
isFrameExpanded,
|
|
180
|
+
select,
|
|
181
|
+
focus,
|
|
182
|
+
openBuilder,
|
|
183
|
+
openDecision,
|
|
184
|
+
closeDecision,
|
|
185
|
+
openDocumentConnect,
|
|
186
|
+
closeDocumentConnect,
|
|
187
|
+
openDocumentImport,
|
|
188
|
+
closeDocumentImport,
|
|
189
|
+
openSpawnPreview,
|
|
190
|
+
closeSpawnPreview,
|
|
191
|
+
openTaskConnect,
|
|
192
|
+
closeTaskConnect,
|
|
193
|
+
openTaskImport,
|
|
194
|
+
closeTaskImport,
|
|
195
|
+
openBootstrap,
|
|
196
|
+
closeBootstrap,
|
|
197
|
+
openGitHub,
|
|
198
|
+
closeGitHub,
|
|
199
|
+
openFragmentLibrary,
|
|
200
|
+
closeFragmentLibrary,
|
|
201
|
+
openRequirementReview,
|
|
202
|
+
closeRequirementReview,
|
|
203
|
+
}
|
|
204
|
+
})
|