@cat-factory/app 0.37.2 → 0.37.3
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/components/board/nodes/BlockNode.vue +32 -13
- package/app/components/bootstrap/BootstrapModal.vue +10 -6
- package/app/components/documents/DocumentImportModal.vue +11 -7
- package/app/components/github/AddServiceFromRepoModal.vue +9 -5
- package/app/components/github/GitHubPanel.vue +8 -4
- package/app/components/kaizen/KaizenPanel.vue +7 -3
- package/app/components/layout/IntegrationsHub.vue +2 -0
- package/app/components/panels/ObservabilityPanel.vue +12 -7
- package/app/components/providers/VendorCredentialsModal.vue +10 -6
- package/app/components/sandbox/SandboxPanel.vue +30 -19
- package/app/components/settings/IssueTrackerPanel.vue +3 -1
- package/app/components/settings/LocalModeSettingsPanel.vue +7 -3
- package/app/components/settings/LocalModelEndpointsPanel.vue +7 -3
- package/app/components/settings/ModelConfigurationPanel.vue +12 -8
- package/app/components/settings/ObservabilityConnectionPanel.vue +16 -12
- package/app/components/settings/OpenRouterCatalogPanel.vue +14 -9
- package/app/components/settings/ProviderConnectionPanel.vue +4 -4
- package/app/components/settings/UserSecretsSection.vue +7 -3
- package/app/components/settings/WorkspaceSettingsPanel.vue +3 -1
- package/app/components/slack/SlackPanel.vue +2 -0
- package/app/composables/useBlockQueries.ts +31 -9
- package/app/pages/index.vue +103 -51
- package/app/stores/board.spec.ts +30 -0
- package/app/stores/board.ts +27 -2
- package/app/stores/brainstorm.ts +11 -0
- package/app/stores/clarity.ts +11 -0
- package/app/stores/consensus.ts +7 -1
- package/app/stores/execution.spec.ts +43 -0
- package/app/stores/execution.ts +19 -0
- package/app/stores/github.ts +17 -0
- package/app/stores/requirements.ts +12 -0
- package/app/stores/workspace.ts +17 -0
- package/package.json +1 -1
|
@@ -35,8 +35,18 @@ const allTasks = computed(() => board.allTasksUnder(props.id))
|
|
|
35
35
|
const taskIds = computed(() => new Set(allTasks.value.map((t) => t.id)))
|
|
36
36
|
const taskCount = computed(() => allTasks.value.length)
|
|
37
37
|
const hasTasks = computed(() => taskCount.value > 0 || modules.value.length > 0)
|
|
38
|
-
|
|
39
|
-
const
|
|
38
|
+
// Single pass over the tasks for both rollups (vs. one filter each).
|
|
39
|
+
const taskStats = computed(() => {
|
|
40
|
+
let merged = 0
|
|
41
|
+
let prReady = 0
|
|
42
|
+
for (const t of allTasks.value) {
|
|
43
|
+
if (t.status === 'done') merged++
|
|
44
|
+
else if (t.status === 'pr_ready') prReady++
|
|
45
|
+
}
|
|
46
|
+
return { merged, prReady }
|
|
47
|
+
})
|
|
48
|
+
const mergedTasks = computed(() => taskStats.value.merged)
|
|
49
|
+
const prTasks = computed(() => taskStats.value.prReady)
|
|
40
50
|
const canvas = computed(() => board.containerSize(props.id))
|
|
41
51
|
|
|
42
52
|
// Frame status is derived from its tasks — services never reach "done".
|
|
@@ -60,10 +70,17 @@ const selected = computed(() => ui.selectedBlockId === props.id)
|
|
|
60
70
|
// kept (gated off) so the prior behaviour is one edit away if we want chips back.
|
|
61
71
|
const showExpanded = computed(() => true)
|
|
62
72
|
|
|
63
|
-
// Surface a pending decision from this frame OR any of its tasks
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
73
|
+
// Surface a pending decision from this frame OR any of its tasks (O(tasks) map
|
|
74
|
+
// lookups, not a scan of every open decision per frame).
|
|
75
|
+
const blockDecisions = computed(() => {
|
|
76
|
+
const byBlock = execution.decisionsByBlock
|
|
77
|
+
const out = [...(byBlock.get(props.id) ?? [])]
|
|
78
|
+
for (const id of taskIds.value) {
|
|
79
|
+
const list = byBlock.get(id)
|
|
80
|
+
if (list) out.push(...list)
|
|
81
|
+
}
|
|
82
|
+
return out
|
|
83
|
+
})
|
|
67
84
|
|
|
68
85
|
function openFirstDecision() {
|
|
69
86
|
const d = blockDecisions.value[0]
|
|
@@ -74,13 +91,15 @@ function openFirstDecision() {
|
|
|
74
91
|
// iterative reviewer gate (requirements-review / clarity-review) that's mid-cycle
|
|
75
92
|
// (incorporating / re-reviewing in the driver), which is background work needing no human,
|
|
76
93
|
// so it stays off the frame's "Approval" badge.
|
|
77
|
-
const blockApprovals = computed(() =>
|
|
78
|
-
execution.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
94
|
+
const blockApprovals = computed(() => {
|
|
95
|
+
const byBlock = execution.approvalsByBlock
|
|
96
|
+
const candidates = [...(byBlock.get(props.id) ?? [])]
|
|
97
|
+
for (const id of taskIds.value) {
|
|
98
|
+
const list = byBlock.get(id)
|
|
99
|
+
if (list) candidates.push(...list)
|
|
100
|
+
}
|
|
101
|
+
return candidates.filter((a) => !reviews.isBackground(a.agentKind, a.blockId))
|
|
102
|
+
})
|
|
84
103
|
|
|
85
104
|
function openFirstApproval() {
|
|
86
105
|
const a = blockApprovals.value[0]
|
|
@@ -24,12 +24,16 @@ const open = computed({
|
|
|
24
24
|
|
|
25
25
|
// Load the workspace's reference architectures + recent jobs, plus (best-effort)
|
|
26
26
|
// the GitHub repos the user can access so the base form can pick from them.
|
|
27
|
-
watch(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
watch(
|
|
28
|
+
open,
|
|
29
|
+
(isOpen) => {
|
|
30
|
+
if (isOpen) {
|
|
31
|
+
void bootstrap.load()
|
|
32
|
+
void loadGitHubRepos()
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
{ immediate: true },
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
async function loadGitHubRepos() {
|
|
35
39
|
try {
|
|
@@ -41,13 +41,17 @@ const sourceDocs = computed(() =>
|
|
|
41
41
|
source.value ? documents.documents.filter((d) => d.source === source.value) : [],
|
|
42
42
|
)
|
|
43
43
|
|
|
44
|
-
watch(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
})
|
|
44
|
+
watch(
|
|
45
|
+
open,
|
|
46
|
+
(isOpen) => {
|
|
47
|
+
if (isOpen) {
|
|
48
|
+
ref_.value = ''
|
|
49
|
+
source.value = ui.documentImport?.source ?? documents.connectedSources[0]?.source ?? undefined
|
|
50
|
+
documents.loadDocuments().catch(() => {})
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
{ immediate: true },
|
|
54
|
+
)
|
|
51
55
|
|
|
52
56
|
async function doImport() {
|
|
53
57
|
const value = ref_.value.trim()
|
|
@@ -41,11 +41,15 @@ async function loadRepos() {
|
|
|
41
41
|
|
|
42
42
|
// On open: ensure we know the connection + which repos the App can access, and
|
|
43
43
|
// the workspace's already-tracked repos (to flag ones already on the board).
|
|
44
|
-
watch(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
watch(
|
|
45
|
+
open,
|
|
46
|
+
(isOpen) => {
|
|
47
|
+
if (!isOpen) return
|
|
48
|
+
resetSelection()
|
|
49
|
+
void loadRepos()
|
|
50
|
+
},
|
|
51
|
+
{ immediate: true },
|
|
52
|
+
)
|
|
49
53
|
|
|
50
54
|
// If the user connects from inside the modal (the not-connected prompt), pull the
|
|
51
55
|
// repo list as soon as the connection is bound.
|
|
@@ -26,10 +26,14 @@ const back = useIntegrationBack(open)
|
|
|
26
26
|
|
|
27
27
|
// On open: refresh projections when connected. The not-connected state renders
|
|
28
28
|
// <GitHubConnect>, which discovers and links installations on its own.
|
|
29
|
-
watch(
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
watch(
|
|
30
|
+
open,
|
|
31
|
+
(isOpen) => {
|
|
32
|
+
if (!isOpen) return
|
|
33
|
+
if (github.connected) void github.load()
|
|
34
|
+
},
|
|
35
|
+
{ immediate: true },
|
|
36
|
+
)
|
|
33
37
|
|
|
34
38
|
function notifyError(title: string, e: unknown) {
|
|
35
39
|
toast.add({
|
|
@@ -12,9 +12,13 @@ const kaizen = useKaizenStore()
|
|
|
12
12
|
|
|
13
13
|
const open = computed(() => ui.kaizenScreenOpen)
|
|
14
14
|
|
|
15
|
-
watch(
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
watch(
|
|
16
|
+
open,
|
|
17
|
+
(isOpen) => {
|
|
18
|
+
if (isOpen) void kaizen.loadOverview()
|
|
19
|
+
},
|
|
20
|
+
{ immediate: true },
|
|
21
|
+
)
|
|
18
22
|
|
|
19
23
|
function close() {
|
|
20
24
|
ui.closeKaizen()
|
|
@@ -44,13 +44,18 @@ const contextLoading = computed(
|
|
|
44
44
|
|
|
45
45
|
// Load (and refresh) whenever a different run's panel opens. Reset to the calls view
|
|
46
46
|
// and load both the calls and the provided-context snapshots.
|
|
47
|
-
watch(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
47
|
+
watch(
|
|
48
|
+
executionId,
|
|
49
|
+
(id) => {
|
|
50
|
+
if (id) {
|
|
51
|
+
view.value = 'calls'
|
|
52
|
+
void observability.load(id)
|
|
53
|
+
void observability.loadContext(id)
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
// Lazy v-if mount: the panel mounts with executionId already set, so load immediately.
|
|
57
|
+
{ immediate: true },
|
|
58
|
+
)
|
|
54
59
|
|
|
55
60
|
const expandedCtx = reactive<Record<string, boolean>>({})
|
|
56
61
|
function toggleCtx(s: AgentContextSnapshot) {
|
|
@@ -52,12 +52,16 @@ const label = ref('')
|
|
|
52
52
|
const token = ref('')
|
|
53
53
|
const busy = ref(false)
|
|
54
54
|
|
|
55
|
-
watch(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
watch(
|
|
56
|
+
open,
|
|
57
|
+
(isOpen) => {
|
|
58
|
+
if (!isOpen) return
|
|
59
|
+
// Honour a deep-linked tab each time the modal opens (e.g. "My subscriptions" → personal).
|
|
60
|
+
activeTab.value = ui.vendorCredentialsTab
|
|
61
|
+
if (workspace.workspaceId) void creds.load(workspace.workspaceId)
|
|
62
|
+
},
|
|
63
|
+
{ immediate: true },
|
|
64
|
+
)
|
|
61
65
|
|
|
62
66
|
/** Step-by-step instructions for the selected vendor. */
|
|
63
67
|
const steps = computed<string[]>(() => {
|
|
@@ -19,9 +19,13 @@ const open = computed({
|
|
|
19
19
|
|
|
20
20
|
const tab = ref<'experiments' | 'prompts' | 'fixtures'>('experiments')
|
|
21
21
|
|
|
22
|
-
watch(
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
watch(
|
|
23
|
+
open,
|
|
24
|
+
(isOpen) => {
|
|
25
|
+
if (isOpen) void store.load()
|
|
26
|
+
},
|
|
27
|
+
{ immediate: true },
|
|
28
|
+
)
|
|
25
29
|
|
|
26
30
|
// ---- experiment builder ----------------------------------------------------
|
|
27
31
|
const agentKind = ref('requirements-review')
|
|
@@ -107,6 +111,21 @@ const gradeByRun = computed(() => {
|
|
|
107
111
|
for (const g of store.detail?.grades ?? []) map.set(g.runId, g)
|
|
108
112
|
return map
|
|
109
113
|
})
|
|
114
|
+
// Fixture id → name, so a row resolves its fixture in O(1) instead of a .find scan.
|
|
115
|
+
const fixtureMap = computed(() => {
|
|
116
|
+
const map = new Map<string, string>()
|
|
117
|
+
for (const f of store.fixtures) map.set(f.id, f.name)
|
|
118
|
+
return map
|
|
119
|
+
})
|
|
120
|
+
// Pre-join each run with its grade + fixture name once, so the results table doesn't
|
|
121
|
+
// re-`.get()` the same grade four times (and `.find()` the fixture) per row on render.
|
|
122
|
+
const detailRows = computed(() =>
|
|
123
|
+
(store.detail?.runs ?? []).map((run) => ({
|
|
124
|
+
run,
|
|
125
|
+
grade: gradeByRun.value.get(run.id) ?? null,
|
|
126
|
+
fixtureName: fixtureMap.value.get(run.fixtureId) ?? run.fixtureId,
|
|
127
|
+
})),
|
|
128
|
+
)
|
|
110
129
|
const selectedRun = ref<SandboxRun | null>(null)
|
|
111
130
|
|
|
112
131
|
function scoreColor(score: number): string {
|
|
@@ -157,8 +176,6 @@ async function archive(prompt: SandboxPromptVersion) {
|
|
|
157
176
|
})
|
|
158
177
|
}
|
|
159
178
|
}
|
|
160
|
-
|
|
161
|
-
const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.name ?? id
|
|
162
179
|
</script>
|
|
163
180
|
|
|
164
181
|
<template>
|
|
@@ -348,7 +365,7 @@ const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.nam
|
|
|
348
365
|
</thead>
|
|
349
366
|
<tbody>
|
|
350
367
|
<tr
|
|
351
|
-
v-for="run in
|
|
368
|
+
v-for="{ run, grade, fixtureName } in detailRows"
|
|
352
369
|
:key="run.id"
|
|
353
370
|
class="cursor-pointer border-t border-slate-800 hover:bg-slate-800/40"
|
|
354
371
|
@click="selectedRun = run"
|
|
@@ -357,14 +374,14 @@ const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.nam
|
|
|
357
374
|
<td class="py-1 pr-2 font-mono text-[11px] text-slate-400">
|
|
358
375
|
{{ run.model }}
|
|
359
376
|
</td>
|
|
360
|
-
<td class="py-1 pr-2 text-slate-400">{{ fixtureName
|
|
377
|
+
<td class="py-1 pr-2 text-slate-400">{{ fixtureName }}</td>
|
|
361
378
|
<td class="py-1 pr-2">
|
|
362
379
|
<span
|
|
363
|
-
v-if="
|
|
364
|
-
:class="scoreColor(
|
|
380
|
+
v-if="grade"
|
|
381
|
+
:class="scoreColor(grade.weightedTotal)"
|
|
365
382
|
class="font-semibold"
|
|
366
383
|
>
|
|
367
|
-
{{
|
|
384
|
+
{{ grade.weightedTotal.toFixed(2) }}
|
|
368
385
|
</span>
|
|
369
386
|
<span v-else-if="run.status === 'failed'" class="text-rose-400"
|
|
370
387
|
>failed</span
|
|
@@ -373,16 +390,10 @@ const fixtureName = (id: string) => store.fixtures.find((f) => f.id === id)?.nam
|
|
|
373
390
|
</td>
|
|
374
391
|
<td class="py-1">
|
|
375
392
|
<span
|
|
376
|
-
v-if="
|
|
377
|
-
:class="
|
|
378
|
-
gradeByRun.get(run.id)!.objective!.pass
|
|
379
|
-
? 'text-emerald-400'
|
|
380
|
-
: 'text-amber-400'
|
|
381
|
-
"
|
|
393
|
+
v-if="grade?.objective"
|
|
394
|
+
:class="grade.objective.pass ? 'text-emerald-400' : 'text-amber-400'"
|
|
382
395
|
>
|
|
383
|
-
{{
|
|
384
|
-
gradeByRun.get(run.id)!.objective!.total
|
|
385
|
-
}}
|
|
396
|
+
{{ grade.objective.caught }}/{{ grade.objective.total }}
|
|
386
397
|
</span>
|
|
387
398
|
<span v-else class="text-slate-600">—</span>
|
|
388
399
|
</td>
|
|
@@ -41,7 +41,9 @@ onMounted(() => {
|
|
|
41
41
|
// probe on open if the navbar hasn't already, so the toggles below reflect reality.
|
|
42
42
|
if (tasks.available === null) void tasks.probe()
|
|
43
43
|
})
|
|
44
|
-
|
|
44
|
+
// `tracker.settings` is reassigned wholesale on hydrate/save, so a reference watch
|
|
45
|
+
// (no deep traversal) catches every change.
|
|
46
|
+
watch(() => tracker.settings, hydrate)
|
|
45
47
|
|
|
46
48
|
// Per-source live state (available = usable now; enabled = offered to the workspace).
|
|
47
49
|
const github = computed(() => tasks.descriptorFor('github'))
|
|
@@ -43,9 +43,13 @@ function syncDraft() {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Load + hydrate the draft whenever the panel opens.
|
|
46
|
-
watch(
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
watch(
|
|
47
|
+
open,
|
|
48
|
+
(isOpen) => {
|
|
49
|
+
if (isOpen) void store.load().then(syncDraft)
|
|
50
|
+
},
|
|
51
|
+
{ immediate: true },
|
|
52
|
+
)
|
|
49
53
|
watch(() => store.settings, syncDraft)
|
|
50
54
|
|
|
51
55
|
async function save() {
|
|
@@ -21,9 +21,13 @@ const back = useIntegrationBack(open)
|
|
|
21
21
|
|
|
22
22
|
// Load the user's endpoints whenever the panel opens (loaded independently of the
|
|
23
23
|
// workspace snapshot, like personal subscriptions).
|
|
24
|
-
watch(
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
watch(
|
|
25
|
+
open,
|
|
26
|
+
(isOpen) => {
|
|
27
|
+
if (isOpen) void store.load()
|
|
28
|
+
},
|
|
29
|
+
{ immediate: true },
|
|
30
|
+
)
|
|
27
31
|
|
|
28
32
|
const RUNNERS: { value: LocalRunner; label: string }[] = (
|
|
29
33
|
Object.keys(LOCAL_RUNNER_LABELS) as LocalRunner[]
|
|
@@ -55,14 +55,18 @@ const filteredKinds = computed(() => {
|
|
|
55
55
|
)
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
-
watch(
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
watch(
|
|
59
|
+
open,
|
|
60
|
+
(isOpen) => {
|
|
61
|
+
if (isOpen) {
|
|
62
|
+
editor.value = null
|
|
63
|
+
filter.value = ''
|
|
64
|
+
void models.ensureLoaded(workspace.workspaceId ?? undefined)
|
|
65
|
+
if (workspace.workspaceId) void creds.load(workspace.workspaceId)
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ immediate: true },
|
|
69
|
+
)
|
|
66
70
|
|
|
67
71
|
onKeyStroke('Escape', () => {
|
|
68
72
|
if (!open.value) return
|
|
@@ -42,18 +42,22 @@ function notifyError(title: string, e: unknown) {
|
|
|
42
42
|
})
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
watch(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
45
|
+
watch(
|
|
46
|
+
open,
|
|
47
|
+
async (isOpen) => {
|
|
48
|
+
if (!isOpen) return
|
|
49
|
+
try {
|
|
50
|
+
await store.ensureLoaded()
|
|
51
|
+
if (store.connection.provider) provider.value = store.connection.provider
|
|
52
|
+
const site = store.connection.summary?.site
|
|
53
|
+
if (site) datadog.site = site
|
|
54
|
+
await store.loadIncident()
|
|
55
|
+
} catch (e) {
|
|
56
|
+
notifyError('Could not load observability settings', e)
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{ immediate: true },
|
|
60
|
+
)
|
|
57
61
|
|
|
58
62
|
async function saveIncident() {
|
|
59
63
|
incidentBusy.value = true
|
|
@@ -51,15 +51,20 @@ const connectingKey = ref(false)
|
|
|
51
51
|
|
|
52
52
|
// Load key state + persisted catalog whenever the panel opens; seed the tick selection,
|
|
53
53
|
// then auto-refresh the live catalog if a key is already connected (no extra click).
|
|
54
|
-
watch(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
watch(
|
|
55
|
+
open,
|
|
56
|
+
(isOpen) => {
|
|
57
|
+
// Lazy v-if mount runs this immediately (see below); guard still skips the closed case.
|
|
58
|
+
if (!isOpen || !workspace.workspaceId) return
|
|
59
|
+
const ws = workspace.workspaceId
|
|
60
|
+
void apiKeys.load(ws).catch(() => {})
|
|
61
|
+
void store.load(ws).then(() => {
|
|
62
|
+
selected.value = new Set(store.enabled.map((m) => m.id))
|
|
63
|
+
if (keyConnected.value && store.browse.length === 0) void refresh()
|
|
64
|
+
})
|
|
65
|
+
},
|
|
66
|
+
{ immediate: true },
|
|
67
|
+
)
|
|
63
68
|
|
|
64
69
|
// The list to show: the live browse list once refreshed, else the persisted enabled set.
|
|
65
70
|
const source = computed<OpenRouterModelMeta[]>(() =>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// see backend/docs/native-environment-adapter.md): a `secret` field → the write-only secret
|
|
8
8
|
// bundle, a non-secret field → providerConfig[key], a `baseUrl` field → baseUrl. A field
|
|
9
9
|
// with a `default` is optional — left blank it falls back to that default.
|
|
10
|
-
import { computed, ref, watch } from 'vue'
|
|
10
|
+
import { computed, ref, toRaw, watch } from 'vue'
|
|
11
11
|
import type { ProviderConnectionKind } from '~/types/providerConnections'
|
|
12
12
|
import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
|
|
13
13
|
import ProvisioningLogsDrawer from '~/components/provisioning/ProvisioningLogsDrawer.vue'
|
|
@@ -163,9 +163,9 @@ function buildManifestPayload(): {
|
|
|
163
163
|
const template = descriptor.value?.manifestTemplate
|
|
164
164
|
if (!template) return null
|
|
165
165
|
const base = descriptor.value?.savedManifest ?? template
|
|
166
|
-
// `base` is a Vue reactive proxy, which structuredClone refuses (DataCloneError).
|
|
167
|
-
//
|
|
168
|
-
const manifest: Record<string, unknown> =
|
|
166
|
+
// `base` is a Vue reactive proxy, which structuredClone refuses (DataCloneError). `toRaw`
|
|
167
|
+
// unwraps it to the underlying plain-JSON config so structuredClone can deep-clone it.
|
|
168
|
+
const manifest: Record<string, unknown> = structuredClone(toRaw(base))
|
|
169
169
|
const providerConfig: Record<string, unknown> = {
|
|
170
170
|
...(manifest.providerConfig as Record<string, unknown> | undefined),
|
|
171
171
|
}
|
|
@@ -40,9 +40,13 @@ function resetDraft() {
|
|
|
40
40
|
if (meta) for (const [k, v] of Object.entries(meta)) values.value[k] = v
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
watch(
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
watch(
|
|
44
|
+
open,
|
|
45
|
+
(isOpen) => {
|
|
46
|
+
if (isOpen) void store.load().then(resetDraft)
|
|
47
|
+
},
|
|
48
|
+
{ immediate: true },
|
|
49
|
+
)
|
|
46
50
|
watch(kind, resetDraft)
|
|
47
51
|
|
|
48
52
|
const secretField = computed<ProviderConfigField | undefined>(() =>
|
|
@@ -87,7 +87,9 @@ function hydrate() {
|
|
|
87
87
|
draft.spendMonthlyLimit = s.spendMonthlyLimit == null ? '' : String(s.spendMonthlyLimit)
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// `store.settings` is always replaced wholesale (store hydrate/update reassign the ref),
|
|
91
|
+
// so tracking the object reference is enough — no deep per-field traversal needed.
|
|
92
|
+
watch(() => store.settings, hydrate, { immediate: true })
|
|
91
93
|
|
|
92
94
|
const saving = ref(false)
|
|
93
95
|
|
|
@@ -9,14 +9,36 @@ import type { Block, BlockStatus } from '~/types/domain'
|
|
|
9
9
|
* them unchanged, so callers and tests are unaffected.
|
|
10
10
|
*/
|
|
11
11
|
export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Single-pass indexes rebuilt once per `blocks` change: id → block,
|
|
14
|
+
* parentId → children (insertion order), epicId → members. Every per-frame
|
|
15
|
+
* query reads these instead of re-scanning the whole array, so a streamed
|
|
16
|
+
* single-block upsert costs ~O(children touched) rather than O(frames × N).
|
|
17
|
+
*/
|
|
18
|
+
const index = computed(() => {
|
|
19
|
+
const byId = new Map<string, Block>()
|
|
20
|
+
const childrenByParent = new Map<string, Block[]>()
|
|
21
|
+
const membersByEpic = new Map<string, Block[]>()
|
|
22
|
+
for (const b of blocks.value) {
|
|
23
|
+
byId.set(b.id, b)
|
|
24
|
+
if (b.parentId) {
|
|
25
|
+
const siblings = childrenByParent.get(b.parentId)
|
|
26
|
+
if (siblings) siblings.push(b)
|
|
27
|
+
else childrenByParent.set(b.parentId, [b])
|
|
28
|
+
}
|
|
29
|
+
if (b.epicId) {
|
|
30
|
+
const members = membersByEpic.get(b.epicId)
|
|
31
|
+
if (members) members.push(b)
|
|
32
|
+
else membersByEpic.set(b.epicId, [b])
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { byId, childrenByParent, membersByEpic }
|
|
16
36
|
})
|
|
17
37
|
|
|
38
|
+
const byId = computed(() => index.value.byId)
|
|
39
|
+
|
|
18
40
|
function getBlock(id: string) {
|
|
19
|
-
return
|
|
41
|
+
return index.value.byId.get(id)
|
|
20
42
|
}
|
|
21
43
|
|
|
22
44
|
/** Top-level architecture blocks (the only ones drawn as Vue Flow nodes). */
|
|
@@ -24,17 +46,17 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
24
46
|
|
|
25
47
|
/** Direct children of a block, in insertion order. */
|
|
26
48
|
function childrenOf(parentId: string) {
|
|
27
|
-
return
|
|
49
|
+
return index.value.childrenByParent.get(parentId) ?? []
|
|
28
50
|
}
|
|
29
51
|
|
|
30
52
|
/** Tasks directly inside a container (a service or a module). */
|
|
31
53
|
function tasksOf(containerId: string) {
|
|
32
|
-
return
|
|
54
|
+
return childrenOf(containerId).filter((b) => b.level === 'task')
|
|
33
55
|
}
|
|
34
56
|
|
|
35
57
|
/** Modules (sub-frames) inside a service. */
|
|
36
58
|
function modulesOf(serviceId: string) {
|
|
37
|
-
return
|
|
59
|
+
return childrenOf(serviceId).filter((b) => b.level === 'module')
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
/** Tasks anywhere under a container — directly, or nested inside its modules. */
|
|
@@ -61,7 +83,7 @@ export function useBlockQueries(blocks: Ref<Block[]>) {
|
|
|
61
83
|
|
|
62
84
|
/** The tasks that belong to an epic (anywhere on the board) via their `epicId`. */
|
|
63
85
|
function epicMembers(epicId: string): Block[] {
|
|
64
|
-
return
|
|
86
|
+
return index.value.membersByEpic.get(epicId) ?? []
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
/** The epic a task belongs to, if any. */
|
package/app/pages/index.vue
CHANGED
|
@@ -5,45 +5,93 @@ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
|
|
|
5
5
|
import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
|
|
6
6
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
7
7
|
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
8
|
+
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
9
|
+
// Always-mounted, fast-path surfaces (opened frequently during a run / board edits, or
|
|
10
|
+
// store-driven so they must react from anywhere — kept eager for snappy open/close).
|
|
8
11
|
import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
|
|
9
12
|
import InspectorPanel from '~/components/panels/InspectorPanel.vue'
|
|
10
13
|
import DecisionModal from '~/components/panels/DecisionModal.vue'
|
|
11
14
|
import AgentStepDetail from '~/components/panels/AgentStepDetail.vue'
|
|
12
15
|
import StepResultViewHost from '~/components/panels/StepResultViewHost.vue'
|
|
13
|
-
import ObservabilityPanel from '~/components/panels/ObservabilityPanel.vue'
|
|
14
|
-
import KaizenPanel from '~/components/kaizen/KaizenPanel.vue'
|
|
15
16
|
import BlockFocusView from '~/components/focus/BlockFocusView.vue'
|
|
16
|
-
import DocumentSourceConnectModal from '~/components/documents/DocumentSourceConnectModal.vue'
|
|
17
|
-
import DocumentImportModal from '~/components/documents/DocumentImportModal.vue'
|
|
18
|
-
import SpawnPreviewModal from '~/components/documents/SpawnPreviewModal.vue'
|
|
19
17
|
import TaskSourceConnectModal from '~/components/tasks/TaskSourceConnectModal.vue'
|
|
20
18
|
import TaskImportModal from '~/components/tasks/TaskImportModal.vue'
|
|
21
19
|
import AddTaskModal from '~/components/board/AddTaskModal.vue'
|
|
22
20
|
import RecurringPipelineModal from '~/components/board/RecurringPipelineModal.vue'
|
|
23
|
-
import BootstrapModal from '~/components/bootstrap/BootstrapModal.vue'
|
|
24
|
-
import AddServiceFromRepoModal from '~/components/github/AddServiceFromRepoModal.vue'
|
|
25
|
-
import GitHubPanel from '~/components/github/GitHubPanel.vue'
|
|
26
|
-
import SlackPanel from '~/components/slack/SlackPanel.vue'
|
|
27
21
|
import GitHubOnboarding from '~/components/github/GitHubOnboarding.vue'
|
|
28
|
-
import FragmentLibraryPanel from '~/components/fragments/FragmentLibraryPanel.vue'
|
|
29
22
|
import CommandBar from '~/components/layout/CommandBar.vue'
|
|
30
|
-
import IntegrationsHub from '~/components/layout/IntegrationsHub.vue'
|
|
31
|
-
import PersonalSetupModal from '~/components/layout/PersonalSetupModal.vue'
|
|
32
|
-
import WorkspaceSettingsPanel from '~/components/settings/WorkspaceSettingsPanel.vue'
|
|
33
|
-
import AccountSettingsPanel from '~/components/settings/AccountSettingsPanel.vue'
|
|
34
|
-
import ObservabilityConnectionPanel from '~/components/settings/ObservabilityConnectionPanel.vue'
|
|
35
|
-
import ProviderConnectionPanel from '~/components/settings/ProviderConnectionPanel.vue'
|
|
36
|
-
import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
|
|
37
|
-
import ModelConfigurationPanel from '~/components/settings/ModelConfigurationPanel.vue'
|
|
38
|
-
import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
|
|
39
|
-
import LocalModeSettingsPanel from '~/components/settings/LocalModeSettingsPanel.vue'
|
|
40
|
-
import SandboxPanel from '~/components/sandbox/SandboxPanel.vue'
|
|
41
|
-
import UserSecretsSection from '~/components/settings/UserSecretsSection.vue'
|
|
42
|
-
import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
|
|
43
|
-
import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
|
|
44
23
|
import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
|
|
45
|
-
|
|
46
|
-
|
|
24
|
+
|
|
25
|
+
// Heavy, rarely-open panels — code-split into their own chunks via defineAsyncComponent
|
|
26
|
+
// and mounted only while their ui open-flag is set (the v-if gates in the template), so
|
|
27
|
+
// they stay out of the initial bundle and don't run setup/watchers while closed.
|
|
28
|
+
const ObservabilityPanel = defineAsyncComponent(
|
|
29
|
+
() => import('~/components/panels/ObservabilityPanel.vue'),
|
|
30
|
+
)
|
|
31
|
+
const KaizenPanel = defineAsyncComponent(() => import('~/components/kaizen/KaizenPanel.vue'))
|
|
32
|
+
const DocumentSourceConnectModal = defineAsyncComponent(
|
|
33
|
+
() => import('~/components/documents/DocumentSourceConnectModal.vue'),
|
|
34
|
+
)
|
|
35
|
+
const DocumentImportModal = defineAsyncComponent(
|
|
36
|
+
() => import('~/components/documents/DocumentImportModal.vue'),
|
|
37
|
+
)
|
|
38
|
+
const SpawnPreviewModal = defineAsyncComponent(
|
|
39
|
+
() => import('~/components/documents/SpawnPreviewModal.vue'),
|
|
40
|
+
)
|
|
41
|
+
const BootstrapModal = defineAsyncComponent(
|
|
42
|
+
() => import('~/components/bootstrap/BootstrapModal.vue'),
|
|
43
|
+
)
|
|
44
|
+
const AddServiceFromRepoModal = defineAsyncComponent(
|
|
45
|
+
() => import('~/components/github/AddServiceFromRepoModal.vue'),
|
|
46
|
+
)
|
|
47
|
+
const GitHubPanel = defineAsyncComponent(() => import('~/components/github/GitHubPanel.vue'))
|
|
48
|
+
const SlackPanel = defineAsyncComponent(() => import('~/components/slack/SlackPanel.vue'))
|
|
49
|
+
const FragmentLibraryPanel = defineAsyncComponent(
|
|
50
|
+
() => import('~/components/fragments/FragmentLibraryPanel.vue'),
|
|
51
|
+
)
|
|
52
|
+
const IntegrationsHub = defineAsyncComponent(
|
|
53
|
+
() => import('~/components/layout/IntegrationsHub.vue'),
|
|
54
|
+
)
|
|
55
|
+
const PersonalSetupModal = defineAsyncComponent(
|
|
56
|
+
() => import('~/components/layout/PersonalSetupModal.vue'),
|
|
57
|
+
)
|
|
58
|
+
const WorkspaceSettingsPanel = defineAsyncComponent(
|
|
59
|
+
() => import('~/components/settings/WorkspaceSettingsPanel.vue'),
|
|
60
|
+
)
|
|
61
|
+
const AccountSettingsPanel = defineAsyncComponent(
|
|
62
|
+
() => import('~/components/settings/AccountSettingsPanel.vue'),
|
|
63
|
+
)
|
|
64
|
+
const ObservabilityConnectionPanel = defineAsyncComponent(
|
|
65
|
+
() => import('~/components/settings/ObservabilityConnectionPanel.vue'),
|
|
66
|
+
)
|
|
67
|
+
const ProviderConnectionPanel = defineAsyncComponent(
|
|
68
|
+
() => import('~/components/settings/ProviderConnectionPanel.vue'),
|
|
69
|
+
)
|
|
70
|
+
const ModelConfigurationPanel = defineAsyncComponent(
|
|
71
|
+
() => import('~/components/settings/ModelConfigurationPanel.vue'),
|
|
72
|
+
)
|
|
73
|
+
const LocalModelEndpointsPanel = defineAsyncComponent(
|
|
74
|
+
() => import('~/components/settings/LocalModelEndpointsPanel.vue'),
|
|
75
|
+
)
|
|
76
|
+
const LocalModeSettingsPanel = defineAsyncComponent(
|
|
77
|
+
() => import('~/components/settings/LocalModeSettingsPanel.vue'),
|
|
78
|
+
)
|
|
79
|
+
const SandboxPanel = defineAsyncComponent(() => import('~/components/sandbox/SandboxPanel.vue'))
|
|
80
|
+
const UserSecretsSection = defineAsyncComponent(
|
|
81
|
+
() => import('~/components/settings/UserSecretsSection.vue'),
|
|
82
|
+
)
|
|
83
|
+
const OpenRouterCatalogPanel = defineAsyncComponent(
|
|
84
|
+
() => import('~/components/settings/OpenRouterCatalogPanel.vue'),
|
|
85
|
+
)
|
|
86
|
+
const VendorCredentialsModal = defineAsyncComponent(
|
|
87
|
+
() => import('~/components/providers/VendorCredentialsModal.vue'),
|
|
88
|
+
)
|
|
89
|
+
const AiProviderOnboardingModal = defineAsyncComponent(
|
|
90
|
+
() => import('~/components/providers/AiProviderOnboardingModal.vue'),
|
|
91
|
+
)
|
|
92
|
+
const AiPresetMismatchDialog = defineAsyncComponent(
|
|
93
|
+
() => import('~/components/providers/AiPresetMismatchDialog.vue'),
|
|
94
|
+
)
|
|
47
95
|
|
|
48
96
|
const workspace = useWorkspaceStore()
|
|
49
97
|
const github = useGitHubStore()
|
|
@@ -170,41 +218,45 @@ watch(
|
|
|
170
218
|
<BlockFocusView />
|
|
171
219
|
</main>
|
|
172
220
|
|
|
221
|
+
<!-- Always-mounted, fast-path surfaces. -->
|
|
173
222
|
<PipelineBuilder />
|
|
174
223
|
<DecisionModal />
|
|
175
224
|
<AgentStepDetail />
|
|
176
225
|
<StepResultViewHost />
|
|
177
|
-
<ObservabilityPanel />
|
|
178
|
-
<KaizenPanel />
|
|
179
|
-
<DocumentSourceConnectModal />
|
|
180
|
-
<DocumentImportModal />
|
|
181
|
-
<SpawnPreviewModal />
|
|
182
226
|
<TaskSourceConnectModal />
|
|
183
227
|
<TaskImportModal />
|
|
184
228
|
<AddTaskModal />
|
|
185
229
|
<RecurringPipelineModal />
|
|
186
|
-
<BootstrapModal />
|
|
187
|
-
<AddServiceFromRepoModal />
|
|
188
|
-
<GitHubPanel />
|
|
189
|
-
<SlackPanel />
|
|
190
|
-
<FragmentLibraryPanel />
|
|
191
230
|
<CommandBar />
|
|
192
|
-
<IntegrationsHub />
|
|
193
|
-
<PersonalSetupModal />
|
|
194
|
-
<WorkspaceSettingsPanel />
|
|
195
|
-
<AccountSettingsPanel />
|
|
196
|
-
<ObservabilityConnectionPanel />
|
|
197
|
-
<ProviderConnectionPanel />
|
|
198
|
-
<ModelConfigurationPanel />
|
|
199
|
-
<LocalModelEndpointsPanel />
|
|
200
|
-
<LocalModeSettingsPanel />
|
|
201
|
-
<SandboxPanel />
|
|
202
|
-
<UserSecretsSection />
|
|
203
|
-
<OpenRouterCatalogPanel />
|
|
204
|
-
<VendorCredentialsModal />
|
|
205
231
|
<PersonalCredentialModal />
|
|
206
|
-
|
|
207
|
-
|
|
232
|
+
|
|
233
|
+
<!-- Lazy panels: mounted only while their ui open-flag is set, so each loads on
|
|
234
|
+
first open (its own chunk) rather than bloating the initial bundle. -->
|
|
235
|
+
<ObservabilityPanel v-if="ui.observabilityInstanceId" />
|
|
236
|
+
<KaizenPanel v-if="ui.kaizenScreenOpen" />
|
|
237
|
+
<DocumentSourceConnectModal v-if="ui.documentConnect" />
|
|
238
|
+
<DocumentImportModal v-if="ui.documentImport" />
|
|
239
|
+
<SpawnPreviewModal v-if="ui.spawnPreview" />
|
|
240
|
+
<BootstrapModal v-if="ui.bootstrapOpen" />
|
|
241
|
+
<AddServiceFromRepoModal v-if="ui.addServiceOpen" />
|
|
242
|
+
<GitHubPanel v-if="ui.githubOpen" />
|
|
243
|
+
<SlackPanel v-if="ui.slackOpen" />
|
|
244
|
+
<FragmentLibraryPanel v-if="ui.fragmentLibraryOpen" />
|
|
245
|
+
<IntegrationsHub v-if="ui.integrationsOpen" />
|
|
246
|
+
<PersonalSetupModal v-if="ui.personalSetupOpen" />
|
|
247
|
+
<WorkspaceSettingsPanel v-if="ui.workspaceSettingsOpen" />
|
|
248
|
+
<AccountSettingsPanel v-if="ui.accountSettingsOpen" />
|
|
249
|
+
<ObservabilityConnectionPanel v-if="ui.observabilityConnectionOpen" />
|
|
250
|
+
<ProviderConnectionPanel v-if="ui.providerConnectionKind" />
|
|
251
|
+
<ModelConfigurationPanel v-if="ui.modelConfigOpen" />
|
|
252
|
+
<LocalModelEndpointsPanel v-if="ui.localModelsOpen" />
|
|
253
|
+
<LocalModeSettingsPanel v-if="ui.localModeSettingsOpen" />
|
|
254
|
+
<SandboxPanel v-if="ui.sandboxOpen" />
|
|
255
|
+
<UserSecretsSection v-if="ui.userSecretsOpen" />
|
|
256
|
+
<OpenRouterCatalogPanel v-if="ui.openRouterOpen" />
|
|
257
|
+
<VendorCredentialsModal v-if="ui.vendorCredentialsOpen" />
|
|
258
|
+
<AiProviderOnboardingModal v-if="ui.aiProviderSetupOpen" />
|
|
259
|
+
<AiPresetMismatchDialog v-if="ui.aiPresetMismatchOpen" />
|
|
208
260
|
</template>
|
|
209
261
|
|
|
210
262
|
<!-- Backend unreachable / bootstrap failed -->
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -87,6 +87,36 @@ describe('board store read getters', () => {
|
|
|
87
87
|
).toEqual(['t2', 't3'])
|
|
88
88
|
})
|
|
89
89
|
|
|
90
|
+
it('epicMembers groups blocks by their epicId (indexed lookup)', () => {
|
|
91
|
+
store.hydrate([
|
|
92
|
+
frame('f1'),
|
|
93
|
+
block('e1', { level: 'epic' }),
|
|
94
|
+
task('t1', 'f1', { epicId: 'e1' }),
|
|
95
|
+
task('t2', 'f1', { epicId: 'e1' }),
|
|
96
|
+
task('t3', 'f1'),
|
|
97
|
+
])
|
|
98
|
+
expect(
|
|
99
|
+
store
|
|
100
|
+
.epicMembers('e1')
|
|
101
|
+
.map((b) => b.id)
|
|
102
|
+
.sort(),
|
|
103
|
+
).toEqual(['t1', 't2'])
|
|
104
|
+
expect(store.epicMembers('none')).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('hydrate reuses the existing object for an unchanged block (stable identity)', () => {
|
|
108
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'a' })])
|
|
109
|
+
const before = store.getBlock('t1')
|
|
110
|
+
// Re-hydrate with an equal-but-distinct snapshot: identity is preserved so unchanged
|
|
111
|
+
// blocks don't force a re-render on a coarse full refresh.
|
|
112
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'a' })])
|
|
113
|
+
expect(store.getBlock('t1')).toBe(before)
|
|
114
|
+
// A block whose content changed gets the fresh object.
|
|
115
|
+
store.hydrate([frame('f1'), task('t1', 'f1', { title: 'b' })])
|
|
116
|
+
expect(store.getBlock('t1')).not.toBe(before)
|
|
117
|
+
expect(store.getBlock('t1')?.title).toBe('b')
|
|
118
|
+
})
|
|
119
|
+
|
|
90
120
|
it('serviceOf walks up to the owning top-level frame', () => {
|
|
91
121
|
store.hydrate([frame('f1'), moduleBlock('m1', 'f1'), task('t1', 'm1'), task('t2', 'f1')])
|
|
92
122
|
expect(store.serviceOf(store.getBlock('t1')!)?.id).toBe('f1')
|
package/app/stores/board.ts
CHANGED
|
@@ -29,9 +29,34 @@ export const useBoardStore = defineStore('board', () => {
|
|
|
29
29
|
const queries = useBlockQueries(blocks)
|
|
30
30
|
const { getBlock } = queries
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Reconcile the cached blocks against a server snapshot, reusing the existing
|
|
34
|
+
* object for any block whose content is unchanged. The server stays authoritative
|
|
35
|
+
* (it replaces optimistic edits and drops deleted blocks), but an unchanged block
|
|
36
|
+
* keeps its identity, so a coarse full-refresh doesn't hand every frame/task a new
|
|
37
|
+
* object reference and force the whole board to re-render — only genuinely changed
|
|
38
|
+
* blocks invalidate. Blocks are emitted in a stable order by the backend mapper, so
|
|
39
|
+
* a per-block JSON compare is a reliable, cheap (refresh is debounced) equality check.
|
|
40
|
+
*/
|
|
41
|
+
// Per-object serialization cache, keyed by block identity so it self-invalidates: a
|
|
42
|
+
// block we keep (same reference) stays cached, while a fresh/`upsert`ed object isn't in
|
|
43
|
+
// the map and is re-serialized. Lets a hydrate stringify each kept block once (the
|
|
44
|
+
// incoming snapshot) rather than twice (existing + incoming).
|
|
45
|
+
const serialized = new WeakMap<Block, string>()
|
|
46
|
+
function jsonFor(b: Block): string {
|
|
47
|
+
let s = serialized.get(b)
|
|
48
|
+
if (s === undefined) {
|
|
49
|
+
s = JSON.stringify(b)
|
|
50
|
+
serialized.set(b, s)
|
|
51
|
+
}
|
|
52
|
+
return s
|
|
53
|
+
}
|
|
33
54
|
function hydrate(next: Block[]) {
|
|
34
|
-
blocks.value
|
|
55
|
+
const prev = new Map(blocks.value.map((b) => [b.id, b]))
|
|
56
|
+
blocks.value = next.map((n) => {
|
|
57
|
+
const existing = prev.get(n.id)
|
|
58
|
+
return existing && jsonFor(existing) === jsonFor(n) ? existing : n
|
|
59
|
+
})
|
|
35
60
|
}
|
|
36
61
|
|
|
37
62
|
/** Insert or replace a block returned by the backend. */
|
package/app/stores/brainstorm.ts
CHANGED
|
@@ -84,6 +84,16 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
84
84
|
sessions.value = { ...sessions.value, [key(session.blockId, session.stage)]: session }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/** Drop all cached sessions + in-flight state (called on workspace switch). */
|
|
88
|
+
function reset() {
|
|
89
|
+
available.value = null
|
|
90
|
+
sessions.value = {}
|
|
91
|
+
running.value = new Set()
|
|
92
|
+
incorporating.value = new Set()
|
|
93
|
+
loadingByKey.value = new Set()
|
|
94
|
+
inFlight.clear()
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
function withFlag(set: typeof running, k: string, on: boolean) {
|
|
88
98
|
const next = new Set(set.value)
|
|
89
99
|
if (on) next.add(k)
|
|
@@ -204,6 +214,7 @@ export const useBrainstormStore = defineStore('brainstorm', () => {
|
|
|
204
214
|
reReview,
|
|
205
215
|
proceed,
|
|
206
216
|
resolveExceeded,
|
|
217
|
+
reset,
|
|
207
218
|
// Patch the cache from a live `brainstorm` stream event.
|
|
208
219
|
upsert: store,
|
|
209
220
|
}
|
package/app/stores/clarity.ts
CHANGED
|
@@ -87,6 +87,16 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
87
87
|
reviews.value = { ...reviews.value, [review.blockId]: review }
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Drop all cached reviews + in-flight state (called on workspace switch). */
|
|
91
|
+
function reset() {
|
|
92
|
+
available.value = null
|
|
93
|
+
reviews.value = {}
|
|
94
|
+
reviewing.value = new Set()
|
|
95
|
+
incorporating.value = new Set()
|
|
96
|
+
loadingByBlock.value = new Set()
|
|
97
|
+
inFlight.clear()
|
|
98
|
+
}
|
|
99
|
+
|
|
90
100
|
function withFlag(set: typeof reviewing, key: string, on: boolean) {
|
|
91
101
|
const next = new Set(set.value)
|
|
92
102
|
if (on) next.add(key)
|
|
@@ -194,6 +204,7 @@ export const useClarityStore = defineStore('clarity', () => {
|
|
|
194
204
|
reReview,
|
|
195
205
|
proceed,
|
|
196
206
|
resolveExceeded,
|
|
207
|
+
reset,
|
|
197
208
|
// Patch the cache from a live `clarity` stream event.
|
|
198
209
|
upsert: store,
|
|
199
210
|
}
|
package/app/stores/consensus.ts
CHANGED
|
@@ -56,5 +56,11 @@ export const useConsensusStore = defineStore('consensus', () => {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
/** Drop all cached sessions + in-flight state (called on workspace switch). */
|
|
60
|
+
function reset() {
|
|
61
|
+
sessions.value = {}
|
|
62
|
+
loading.value = new Set()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return { sessions, sessionFor, isLoading, load, upsert, reset }
|
|
60
66
|
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import { useExecutionStore } from '~/stores/execution'
|
|
3
|
+
import type { ExecutionInstance } from '~/types/domain'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal instance shape — the `decisionsByBlock` / `approvalsByBlock` getters only read
|
|
7
|
+
* `id`, `blockId` and each step's `{ decision, approval, agentKind }`, so a cast keeps the
|
|
8
|
+
* fixtures focused on the grouping behaviour rather than the full wire contract.
|
|
9
|
+
*/
|
|
10
|
+
function instance(id: string, blockId: string, steps: unknown[]): ExecutionInstance {
|
|
11
|
+
return { id, blockId, steps } as unknown as ExecutionInstance
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('execution store gate grouping', () => {
|
|
15
|
+
let store: ReturnType<typeof useExecutionStore>
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
store = useExecutionStore()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('decisionsByBlock groups open (unchosen) decisions by block', () => {
|
|
21
|
+
store.hydrate([
|
|
22
|
+
instance('e1', 'b1', [
|
|
23
|
+
{ agentKind: 'coder', decision: { id: 'd1', chosen: null } },
|
|
24
|
+
{ agentKind: 'coder', decision: { id: 'd2', chosen: 'yes' } }, // chosen ⇒ excluded
|
|
25
|
+
]),
|
|
26
|
+
instance('e2', 'b2', [{ agentKind: 'architect', decision: { id: 'd3', chosen: null } }]),
|
|
27
|
+
])
|
|
28
|
+
expect(store.decisionsByBlock.get('b1')?.map((d) => d.decision.id)).toEqual(['d1'])
|
|
29
|
+
expect(store.decisionsByBlock.get('b2')?.map((d) => d.decision.id)).toEqual(['d3'])
|
|
30
|
+
expect(store.decisionsByBlock.has('missing')).toBe(false)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('approvalsByBlock groups pending approvals by block', () => {
|
|
34
|
+
store.hydrate([
|
|
35
|
+
instance('e1', 'b1', [
|
|
36
|
+
{ agentKind: 'merger', approval: { id: 'a1', status: 'pending' } },
|
|
37
|
+
{ agentKind: 'merger', approval: { id: 'a2', status: 'approved' } }, // not pending ⇒ excluded
|
|
38
|
+
]),
|
|
39
|
+
])
|
|
40
|
+
expect(store.approvalsByBlock.get('b1')?.map((a) => a.approval.id)).toEqual(['a1'])
|
|
41
|
+
expect(store.approvalsByBlock.get('b2')).toBeUndefined()
|
|
42
|
+
})
|
|
43
|
+
})
|
package/app/stores/execution.ts
CHANGED
|
@@ -106,6 +106,23 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
106
106
|
return out
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Open decisions/approvals grouped by the block they belong to, so a board card
|
|
111
|
+
* resolves its own + its tasks' pending gates with O(1) lookups instead of
|
|
112
|
+
* re-filtering the global lists once per frame on every execution event.
|
|
113
|
+
*/
|
|
114
|
+
function groupByBlock<T extends { blockId: string }>(items: T[]): Map<string, T[]> {
|
|
115
|
+
const map = new Map<string, T[]>()
|
|
116
|
+
for (const item of items) {
|
|
117
|
+
const list = map.get(item.blockId)
|
|
118
|
+
if (list) list.push(item)
|
|
119
|
+
else map.set(item.blockId, [item])
|
|
120
|
+
}
|
|
121
|
+
return map
|
|
122
|
+
}
|
|
123
|
+
const decisionsByBlock = computed(() => groupByBlock(openDecisions.value))
|
|
124
|
+
const approvalsByBlock = computed(() => groupByBlock(openApprovals.value))
|
|
125
|
+
|
|
109
126
|
/**
|
|
110
127
|
* Start `pipeline` against a block; the server marks the block in-progress. A block
|
|
111
128
|
* pinned to an individual-usage model (Claude) needs the initiator's personal
|
|
@@ -279,6 +296,8 @@ export const useExecutionStore = defineStore('execution', () => {
|
|
|
279
296
|
pendingDecisionCount,
|
|
280
297
|
openDecisions,
|
|
281
298
|
openApprovals,
|
|
299
|
+
decisionsByBlock,
|
|
300
|
+
approvalsByBlock,
|
|
282
301
|
pendingApprovalCount,
|
|
283
302
|
start,
|
|
284
303
|
resolveDecision,
|
package/app/stores/github.ts
CHANGED
|
@@ -259,6 +259,22 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
259
259
|
return api.commentGitHubIssue(workspace.requireId(), repoGithubId, number, body)
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Drop the per-workspace projection + connection state (called on workspace switch)
|
|
264
|
+
* so the prior workspace's repos/PRs/issues don't linger until the re-probe + reload
|
|
265
|
+
* land. `available` returns to `null` (unknown) so the onboarding gate re-resolves.
|
|
266
|
+
*/
|
|
267
|
+
function reset() {
|
|
268
|
+
available.value = null
|
|
269
|
+
connection.value = null
|
|
270
|
+
installations.value = []
|
|
271
|
+
repos.value = []
|
|
272
|
+
availableRepos.value = []
|
|
273
|
+
pulls.value = []
|
|
274
|
+
issues.value = []
|
|
275
|
+
branches.value = {}
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
return {
|
|
263
279
|
available,
|
|
264
280
|
connection,
|
|
@@ -300,5 +316,6 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
300
316
|
openPullRequest,
|
|
301
317
|
mergePullRequest,
|
|
302
318
|
comment,
|
|
319
|
+
reset,
|
|
303
320
|
}
|
|
304
321
|
})
|
|
@@ -97,6 +97,17 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
97
97
|
reviews.value = { ...reviews.value, [review.blockId]: review }
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Drop all cached reviews + in-flight state (called on workspace switch). */
|
|
101
|
+
function reset() {
|
|
102
|
+
available.value = null
|
|
103
|
+
reviews.value = {}
|
|
104
|
+
reviewing.value = new Set()
|
|
105
|
+
incorporating.value = new Set()
|
|
106
|
+
recommending.value = new Set()
|
|
107
|
+
loadingByBlock.value = new Set()
|
|
108
|
+
inFlight.clear()
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
function withFlag(set: typeof reviewing, key: string, on: boolean) {
|
|
101
112
|
const next = new Set(set.value)
|
|
102
113
|
if (on) next.add(key)
|
|
@@ -265,6 +276,7 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|
|
265
276
|
acceptRecommendation,
|
|
266
277
|
rejectRecommendation,
|
|
267
278
|
reRequestRecommendation,
|
|
279
|
+
reset,
|
|
268
280
|
// Patch the cache from a live `requirements` stream event.
|
|
269
281
|
upsert: store,
|
|
270
282
|
}
|
package/app/stores/workspace.ts
CHANGED
|
@@ -16,6 +16,11 @@ import { useRecurringPipelinesStore } from '~/stores/recurringPipelines'
|
|
|
16
16
|
import { useServicesStore } from '~/stores/services'
|
|
17
17
|
import { useAgentsStore } from '~/stores/agents'
|
|
18
18
|
import { useTrackerStore } from '~/stores/tracker'
|
|
19
|
+
import { useRequirementsStore } from '~/stores/requirements'
|
|
20
|
+
import { useClarityStore } from '~/stores/clarity'
|
|
21
|
+
import { useBrainstormStore } from '~/stores/brainstorm'
|
|
22
|
+
import { useConsensusStore } from '~/stores/consensus'
|
|
23
|
+
import { useGitHubStore } from '~/stores/github'
|
|
19
24
|
|
|
20
25
|
/**
|
|
21
26
|
* Owns the active workspace and bootstraps the app against the backend. On load
|
|
@@ -58,6 +63,18 @@ export const useWorkspaceStore = defineStore(
|
|
|
58
63
|
|
|
59
64
|
/** Push a snapshot into the data stores. */
|
|
60
65
|
function hydrate(snapshot: WorkspaceSnapshot) {
|
|
66
|
+
// A change of active board (or the first load) — drop the per-block caches that are
|
|
67
|
+
// NOT part of the snapshot (reviews, brainstorm/consensus sessions, the GitHub
|
|
68
|
+
// projection) so a switched-to board never shows the previous one's stale state.
|
|
69
|
+
// These are lazily reloaded/re-probed per board, so clearing on a same-board refresh
|
|
70
|
+
// would needlessly wipe an open review window — hence only on an actual id change.
|
|
71
|
+
if (workspaceId.value !== snapshot.workspace.id) {
|
|
72
|
+
useRequirementsStore().reset()
|
|
73
|
+
useClarityStore().reset()
|
|
74
|
+
useBrainstormStore().reset()
|
|
75
|
+
useConsensusStore().reset()
|
|
76
|
+
useGitHubStore().reset()
|
|
77
|
+
}
|
|
61
78
|
workspaceId.value = snapshot.workspace.id
|
|
62
79
|
spend.value = snapshot.spend ?? null
|
|
63
80
|
// Keep the board list in step (e.g. a freshly created board, or a rename).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.3",
|
|
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",
|