@cat-factory/app 0.315.4 → 0.315.6
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/bootstrap/BootstrapModal.vue +1 -1
- package/app/components/github/AddServiceFromRepoModal.vue +2 -2
- package/app/components/github/GitHubPanel.vue +1 -1
- package/app/components/panels/InspectorPanel.vue +6 -1
- package/app/components/panels/inspector/ServiceTestConfig.vue +2 -2
- package/app/components/pipeline/PipelineBuilder.vue +4 -0
- package/app/components/settings/ModelConfigurationPanel.vue +9 -1
- package/app/stores/github/connection.ts +2 -4
- package/app/stores/github/context.ts +5 -1
- package/app/stores/github/projectionLoad.ts +127 -0
- package/app/stores/github/repoActions.ts +7 -2
- package/app/stores/github.spec.ts +355 -1
- package/app/stores/github.ts +21 -22
- package/app/stores/ui/builderModelConfigHandoff.spec.ts +88 -0
- package/app/stores/ui/modals.ts +78 -12
- package/i18n/locales/de.json +1 -0
- package/i18n/locales/en.json +1 -0
- package/i18n/locales/es.json +1 -0
- package/i18n/locales/fr.json +1 -0
- package/i18n/locales/he.json +1 -0
- package/i18n/locales/it.json +1 -0
- package/i18n/locales/ja.json +1 -0
- package/i18n/locales/pl.json +1 -0
- package/i18n/locales/tr.json +1 -0
- package/i18n/locales/uk.json +1 -0
- package/package.json +1 -1
|
@@ -59,10 +59,10 @@ const adding = ref(false)
|
|
|
59
59
|
async function loadRepos() {
|
|
60
60
|
try {
|
|
61
61
|
await github.probe()
|
|
62
|
-
// Load
|
|
62
|
+
// Load the linked-repo projection once per board (to flag repos already on the board). The
|
|
63
63
|
// available-repos list is NOT prefetched — it's searched server-side on demand once
|
|
64
64
|
// the user types (see the repoQueryRaw watcher), so a wide install isn't shipped whole.
|
|
65
|
-
|
|
65
|
+
await github.refresh()
|
|
66
66
|
} catch {
|
|
67
67
|
// Integration off / unreachable → the picker stays empty, GitHubConnect shows.
|
|
68
68
|
}
|
|
@@ -24,10 +24,15 @@ const recurring = useRecurringPipelinesStore()
|
|
|
24
24
|
const requirements = useRequirementsStore()
|
|
25
25
|
const access = useWorkspaceAccess()
|
|
26
26
|
const { t } = useI18n()
|
|
27
|
+
const { present } = usePipelineErrorToast()
|
|
27
28
|
|
|
28
29
|
onMounted(() => {
|
|
29
30
|
fragments.ensureLoaded()
|
|
30
|
-
|
|
31
|
+
// The repo list behind the inspector's repo link and the service config's detect-from-repo
|
|
32
|
+
// lookup. Both read the store, so this one call serves them.
|
|
33
|
+
github.ensureLoaded().catch((error: unknown) => {
|
|
34
|
+
present(error, 'github.connect.errors.loadProjection')
|
|
35
|
+
})
|
|
31
36
|
})
|
|
32
37
|
|
|
33
38
|
const block = computed<Block | undefined>(() =>
|
|
@@ -50,10 +50,10 @@ const ui = useUiStore()
|
|
|
50
50
|
const { t, te } = useI18n()
|
|
51
51
|
|
|
52
52
|
// The custom-manifest-type catalog feeds the `custom` picker. Cheap + shared (coalesced).
|
|
53
|
-
// The repo list
|
|
53
|
+
// The repo list behind the detect-from-repo affordance (owner/name lookup) is loaded by the
|
|
54
|
+
// surface that hosts this section: the inspector panel, or the add-service modal on open.
|
|
54
55
|
onMounted(() => {
|
|
55
56
|
void infra.ensureLoaded()
|
|
56
|
-
void github.ensureLoaded()
|
|
57
57
|
})
|
|
58
58
|
|
|
59
59
|
// The service's declared provision type (absent ⇒ treated as `infraless`: no environment
|
|
@@ -252,6 +252,8 @@ const open = computed({
|
|
|
252
252
|
// knows whether to offer the post-release-health gate (it's loaded on demand, not from
|
|
253
253
|
// the snapshot). Best-effort: a failure just leaves the gate hidden.
|
|
254
254
|
onModalOpen(open, () => {
|
|
255
|
+
// Coming back from the model configuration, where none of this changed.
|
|
256
|
+
if (ui.consumeBuilderReturn()) return
|
|
255
257
|
releaseHealth.load().catch(() => {})
|
|
256
258
|
// The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
|
|
257
259
|
// Best-effort: the builder is fully usable without it, and a deployment that wires no
|
|
@@ -404,6 +406,7 @@ const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelin
|
|
|
404
406
|
:title="t('pipeline.builder.title')"
|
|
405
407
|
side="left"
|
|
406
408
|
:ui="{ content: 'max-w-[90vw] sm:max-w-2xl lg:max-w-5xl xl:max-w-6xl' }"
|
|
409
|
+
@after:leave="ui.builderLeft()"
|
|
407
410
|
>
|
|
408
411
|
<template #body>
|
|
409
412
|
<!-- On compact viewports the three sections stack and the slideover body
|
|
@@ -443,6 +446,7 @@ const { toggleArchive, toggleDefault, edit, removePipeline, clone } = usePipelin
|
|
|
443
446
|
size="xs"
|
|
444
447
|
icon="i-lucide-cpu"
|
|
445
448
|
:title="t('pipeline.builder.configureModelsTooltip')"
|
|
449
|
+
data-testid="pipeline-builder-configure-models"
|
|
446
450
|
@click="ui.openModelConfig()"
|
|
447
451
|
>
|
|
448
452
|
{{ t('pipeline.builder.configureModels') }}
|
|
@@ -311,6 +311,7 @@ async function save() {
|
|
|
311
311
|
<Transition name="reader-fade">
|
|
312
312
|
<div
|
|
313
313
|
v-if="open"
|
|
314
|
+
data-testid="model-configuration-panel"
|
|
314
315
|
class="z-50 flex-col bg-app-950/96 backdrop-blur-sm"
|
|
315
316
|
:class="FULL_BLEED_TAKEOVER_CLASS"
|
|
316
317
|
role="dialog"
|
|
@@ -377,7 +378,13 @@ async function save() {
|
|
|
377
378
|
</template>
|
|
378
379
|
</i18n-t>
|
|
379
380
|
</p>
|
|
380
|
-
<UButton
|
|
381
|
+
<UButton
|
|
382
|
+
icon="i-lucide-plus"
|
|
383
|
+
size="sm"
|
|
384
|
+
class="shrink-0"
|
|
385
|
+
data-testid="model-preset-new"
|
|
386
|
+
@click="startCreate"
|
|
387
|
+
>
|
|
381
388
|
{{ t('settings.modelConfiguration.list.newPreset') }}
|
|
382
389
|
</UButton>
|
|
383
390
|
</div>
|
|
@@ -491,6 +498,7 @@ async function save() {
|
|
|
491
498
|
</SectionLabel>
|
|
492
499
|
<UInput
|
|
493
500
|
v-model="editor.name"
|
|
501
|
+
data-testid="model-preset-name"
|
|
494
502
|
:placeholder="t('settings.modelConfiguration.editor.namePlaceholder')"
|
|
495
503
|
size="sm"
|
|
496
504
|
class="w-full"
|
|
@@ -14,7 +14,7 @@ import type { GitHubStoreContext } from './context'
|
|
|
14
14
|
*/
|
|
15
15
|
export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
|
|
16
16
|
const { api, workspace, available, connection, installations, loadingInstallations } = ctx
|
|
17
|
-
const {
|
|
17
|
+
const { availableRepos, branches, syncing, load, clearProjection } = ctx
|
|
18
18
|
|
|
19
19
|
/** The URL a workspace owner visits to install the App against this workspace. */
|
|
20
20
|
function getInstallUrl(): Promise<string> {
|
|
@@ -48,10 +48,8 @@ export function createGitHubConnectionActions(ctx: GitHubStoreContext) {
|
|
|
48
48
|
// Backends predating the discriminator omit `provider`; those connections are GitHub App ones.
|
|
49
49
|
await DISCONNECT_BY_PROVIDER[connection.value?.provider ?? 'github'](workspace.requireId())
|
|
50
50
|
connection.value = null
|
|
51
|
-
|
|
51
|
+
clearProjection()
|
|
52
52
|
availableRepos.value = []
|
|
53
|
-
pulls.value = []
|
|
54
|
-
issues.value = []
|
|
55
53
|
branches.value = {}
|
|
56
54
|
}
|
|
57
55
|
|
|
@@ -45,6 +45,10 @@ export interface GitHubStoreContext {
|
|
|
45
45
|
syncing: Ref<boolean>
|
|
46
46
|
/** Whether an App installation is bound (the store's `connected` computed). */
|
|
47
47
|
connected: Readonly<Ref<boolean>>
|
|
48
|
-
/** Reload the cached repos/PRs/issues projection (the store's `load`). */
|
|
48
|
+
/** Reload the cached repos/PRs/issues projection after a write (the store's `load`). */
|
|
49
49
|
load: () => Promise<void>
|
|
50
|
+
/** Record a direct write to the projection, so a read that started before it cannot land. */
|
|
51
|
+
markWritten: () => void
|
|
52
|
+
/** Empty the repos/PRs/issues projection and mark it unloaded, so `ensureLoaded` reads it again. */
|
|
53
|
+
clearProjection: () => void
|
|
50
54
|
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { GitHubConnection, GitHubIssue, GitHubPullRequest, GitHubRepo } from '~/types/domain'
|
|
3
|
+
import type { GitHubStoreContext } from '~/stores/github/context'
|
|
4
|
+
|
|
5
|
+
/** What the projection load reads and writes. The store owns every ref. */
|
|
6
|
+
interface ProjectionLoadDeps {
|
|
7
|
+
api: GitHubStoreContext['api']
|
|
8
|
+
workspace: GitHubStoreContext['workspace']
|
|
9
|
+
connection: Readonly<Ref<GitHubConnection | null>>
|
|
10
|
+
repos: Ref<GitHubRepo[]>
|
|
11
|
+
pulls: Ref<GitHubPullRequest[]>
|
|
12
|
+
issues: Ref<GitHubIssue[]>
|
|
13
|
+
loading: Ref<boolean>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ProjectionLoad {
|
|
17
|
+
/** Read the projection again after a write, never sharing a read that started before it. */
|
|
18
|
+
load: () => Promise<void>
|
|
19
|
+
/** Read the projection again, sharing a read already in flight for the same connection. */
|
|
20
|
+
refresh: () => Promise<void>
|
|
21
|
+
/** Read the projection unless it is already loaded for the current connection. */
|
|
22
|
+
ensureLoaded: () => Promise<void>
|
|
23
|
+
/** Record a direct write to the projection, so a read that started before it cannot land. */
|
|
24
|
+
markWritten: () => void
|
|
25
|
+
/** Empty the lists and forget the load in one step. */
|
|
26
|
+
clearProjection: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface InFlightRead {
|
|
30
|
+
key: string
|
|
31
|
+
promise: Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The repos / pull requests / issues projection read, and the record of what it is loaded for.
|
|
36
|
+
*
|
|
37
|
+
* One generation counter answers every question about whether a read may land. A read takes the
|
|
38
|
+
* next number when it starts, and every direct write to the projection (`markWritten()`, which
|
|
39
|
+
* `clearProjection()` calls too) takes one as well. A read lands only if its number is still the
|
|
40
|
+
* latest and it was started for the connection that is current when it finishes, so a result that
|
|
41
|
+
* a newer read, a write, a disconnect or a board switch overtook is dropped.
|
|
42
|
+
*
|
|
43
|
+
* The projection counts as loaded for a key made of the workspace and the connection. A connected
|
|
44
|
+
* workspace with no linked repos is loaded: the empty list is its answer. A different workspace or
|
|
45
|
+
* a new connection is a different key, so a reinstall is read again without anyone telling this
|
|
46
|
+
* module that it happened.
|
|
47
|
+
*
|
|
48
|
+
* There are three doors. `ensureLoaded()` reads only when nothing is loaded, `refresh()` is a
|
|
49
|
+
* deliberate re-read that still shares a read in flight, and `load()` is the re-read after a write,
|
|
50
|
+
* which starts its own read so it never returns what the write replaced.
|
|
51
|
+
*/
|
|
52
|
+
export function createGitHubProjectionLoad(deps: ProjectionLoadDeps): ProjectionLoad {
|
|
53
|
+
const { api, workspace, connection, repos, pulls, issues, loading } = deps
|
|
54
|
+
|
|
55
|
+
let generation = 0
|
|
56
|
+
let loadedKey: string | null = null
|
|
57
|
+
let inFlight: InFlightRead | null = null
|
|
58
|
+
|
|
59
|
+
/** What the projection is read for. Null while there is nothing to read: no board or no connection. */
|
|
60
|
+
function currentKey(): string | null {
|
|
61
|
+
const conn = connection.value
|
|
62
|
+
if (!workspace.workspaceId || !conn) return null
|
|
63
|
+
return `${workspace.workspaceId}:${conn.provider}:${conn.installationId}:${conn.connectedAt}`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function read(mine: number, key: string, askedFor: string): Promise<void> {
|
|
67
|
+
const [r, p, i] = await Promise.all([
|
|
68
|
+
api.listGitHubRepos(askedFor),
|
|
69
|
+
api.listGitHubPullRequests(askedFor),
|
|
70
|
+
api.listGitHubIssues(askedFor),
|
|
71
|
+
])
|
|
72
|
+
if (mine !== generation || key !== currentKey()) return
|
|
73
|
+
repos.value = r
|
|
74
|
+
pulls.value = p
|
|
75
|
+
issues.value = i
|
|
76
|
+
loadedKey = key
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function start(key: string): Promise<void> {
|
|
80
|
+
const mine = ++generation
|
|
81
|
+
loading.value = true
|
|
82
|
+
const entry: InFlightRead = {
|
|
83
|
+
key,
|
|
84
|
+
promise: read(mine, key, workspace.requireId()).finally(() => {
|
|
85
|
+
// Only the read that still owns the slot settles the flag. One that a write, a clear or a
|
|
86
|
+
// newer read replaced must not report the projection as settled.
|
|
87
|
+
if (inFlight === entry) {
|
|
88
|
+
inFlight = null
|
|
89
|
+
loading.value = false
|
|
90
|
+
}
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
inFlight = entry
|
|
94
|
+
return entry.promise
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function markWritten(): void {
|
|
98
|
+
generation++
|
|
99
|
+
inFlight = null
|
|
100
|
+
loading.value = false
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
load: () => {
|
|
105
|
+
const key = currentKey()
|
|
106
|
+
return key ? start(key) : Promise.resolve()
|
|
107
|
+
},
|
|
108
|
+
refresh: () => {
|
|
109
|
+
const key = currentKey()
|
|
110
|
+
if (!key) return Promise.resolve()
|
|
111
|
+
return inFlight?.key === key ? inFlight.promise : start(key)
|
|
112
|
+
},
|
|
113
|
+
ensureLoaded: () => {
|
|
114
|
+
const key = currentKey()
|
|
115
|
+
if (!key || loadedKey === key) return Promise.resolve()
|
|
116
|
+
return inFlight?.key === key ? inFlight.promise : start(key)
|
|
117
|
+
},
|
|
118
|
+
markWritten,
|
|
119
|
+
clearProjection: () => {
|
|
120
|
+
markWritten()
|
|
121
|
+
loadedKey = null
|
|
122
|
+
repos.value = []
|
|
123
|
+
pulls.value = []
|
|
124
|
+
issues.value = []
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -16,7 +16,7 @@ import { pullKey, type GitHubStoreContext } from './context'
|
|
|
16
16
|
*/
|
|
17
17
|
export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
18
18
|
const { api, workspace, connected, repos, availableRepos, loadingAvailable, savingRepos } = ctx
|
|
19
|
-
const { branches, repoFiles, upsertPull, getPull, load } = ctx
|
|
19
|
+
const { branches, repoFiles, upsertPull, getPull, load, markWritten } = ctx
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Load the repos the installation can access, with this workspace's link state.
|
|
@@ -55,6 +55,7 @@ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
|
55
55
|
savingRepos.value = true
|
|
56
56
|
try {
|
|
57
57
|
repos.value = await api.setGitHubLinkedRepos(workspace.requireId(), repoGithubIds)
|
|
58
|
+
markWritten()
|
|
58
59
|
// Reflect the new link state in the picker and refresh PRs/issues.
|
|
59
60
|
const linked = new Set(repoGithubIds)
|
|
60
61
|
availableRepos.value = availableRepos.value.map((r) => ({
|
|
@@ -117,6 +118,7 @@ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
|
117
118
|
async function openPullRequest(repoGithubId: number, input: OpenPullRequestInput) {
|
|
118
119
|
const pr = await api.openGitHubPullRequest(workspace.requireId(), repoGithubId, input)
|
|
119
120
|
upsertPull(pr)
|
|
121
|
+
markWritten()
|
|
120
122
|
return pr
|
|
121
123
|
}
|
|
122
124
|
|
|
@@ -128,7 +130,10 @@ export function createGitHubRepoActions(ctx: GitHubStoreContext) {
|
|
|
128
130
|
await api.mergeGitHubPullRequest(workspace.requireId(), repoGithubId, number, input)
|
|
129
131
|
// Optimistically reflect the merge until the next sync confirms it.
|
|
130
132
|
const existing = getPull(pullKey(repoGithubId, number))
|
|
131
|
-
if (existing)
|
|
133
|
+
if (existing) {
|
|
134
|
+
upsertPull({ ...existing, state: 'closed', merged: true })
|
|
135
|
+
markWritten()
|
|
136
|
+
}
|
|
132
137
|
}
|
|
133
138
|
|
|
134
139
|
function comment(repoGithubId: number, number: number, body: string) {
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect, vi, type Mock } from 'vitest'
|
|
2
2
|
import { useGitHubStore } from '~/stores/github'
|
|
3
3
|
import { useWorkspaceStore } from '~/stores/workspace'
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
GitHubConnection,
|
|
6
|
+
GitHubPatCheck,
|
|
7
|
+
GitHubPullRequest,
|
|
8
|
+
GitHubRepo,
|
|
9
|
+
VcsConnectOption,
|
|
10
|
+
} from '~/types/domain'
|
|
5
11
|
|
|
6
12
|
// The VCS connect surface of the (single, GitHub-shaped) repo store: which connect methods the
|
|
7
13
|
// deployment offers, the per-workspace GitLab PAT connect, and the provider-routed disconnect.
|
|
@@ -393,3 +399,351 @@ describe('github store — GitHub PAT credential check', () => {
|
|
|
393
399
|
await vi.waitFor(() => expect(getGitHubPatCheck).toHaveBeenCalledTimes(2))
|
|
394
400
|
})
|
|
395
401
|
})
|
|
402
|
+
|
|
403
|
+
// Two inspector panels call `ensureLoaded()` on the same frame selection. The probe is
|
|
404
|
+
// single-flighted, so both callers share it, but the projection read behind it must be shared too.
|
|
405
|
+
describe('github store: ensureLoaded', () => {
|
|
406
|
+
const repo: GitHubRepo = {
|
|
407
|
+
githubId: 1,
|
|
408
|
+
installationId: 42,
|
|
409
|
+
owner: 'acme',
|
|
410
|
+
name: 'api',
|
|
411
|
+
defaultBranch: 'main',
|
|
412
|
+
private: false,
|
|
413
|
+
syncedAt: 0,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
it('loads the projection once when two callers ask at the same time', async () => {
|
|
417
|
+
// One linked repo, so the second caller cannot load again only because the list is empty.
|
|
418
|
+
const api = stubApi({
|
|
419
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
420
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
421
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
422
|
+
})
|
|
423
|
+
const github = storeWithWorkspace()
|
|
424
|
+
|
|
425
|
+
await Promise.all([github.ensureLoaded(), github.ensureLoaded()])
|
|
426
|
+
|
|
427
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
428
|
+
expect(api.listGitHubPullRequests).toHaveBeenCalledTimes(1)
|
|
429
|
+
expect(api.listGitHubIssues).toHaveBeenCalledTimes(1)
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
// A connected workspace with no linked repos counts as loaded: the empty list is its answer, so
|
|
433
|
+
// a later call does not read it again.
|
|
434
|
+
it('does not load again for a connected workspace with no repos', async () => {
|
|
435
|
+
const api = stubApi({
|
|
436
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
437
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
438
|
+
listGitHubRepos: vi.fn().mockResolvedValue([]),
|
|
439
|
+
})
|
|
440
|
+
const github = storeWithWorkspace()
|
|
441
|
+
|
|
442
|
+
await github.ensureLoaded()
|
|
443
|
+
await github.ensureLoaded()
|
|
444
|
+
|
|
445
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
446
|
+
expect(api.listGitHubPullRequests).toHaveBeenCalledTimes(1)
|
|
447
|
+
expect(api.listGitHubIssues).toHaveBeenCalledTimes(1)
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// A board switch resets the store, and a switch back lands on the same workspace id. The
|
|
451
|
+
// cleared projection must be read again, even though that id was loaded before.
|
|
452
|
+
it('loads again after a reset clears the projection', async () => {
|
|
453
|
+
const api = stubApi({
|
|
454
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
455
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
456
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
457
|
+
})
|
|
458
|
+
const github = storeWithWorkspace()
|
|
459
|
+
|
|
460
|
+
await github.ensureLoaded()
|
|
461
|
+
github.reset()
|
|
462
|
+
await github.ensureLoaded()
|
|
463
|
+
|
|
464
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
465
|
+
expect(github.repos).toEqual([repo])
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('loads again after a load that failed', async () => {
|
|
469
|
+
const api = stubApi({
|
|
470
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
471
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
472
|
+
listGitHubRepos: vi.fn().mockRejectedValueOnce(new Error('500')).mockResolvedValue([repo]),
|
|
473
|
+
})
|
|
474
|
+
const github = storeWithWorkspace()
|
|
475
|
+
|
|
476
|
+
await expect(github.ensureLoaded()).rejects.toThrow('500')
|
|
477
|
+
await github.ensureLoaded()
|
|
478
|
+
|
|
479
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
480
|
+
expect(github.repos).toEqual([repo])
|
|
481
|
+
})
|
|
482
|
+
|
|
483
|
+
// `load()` is how every write refreshes the projection. A write that lands while an
|
|
484
|
+
// `ensureLoaded()` read is in flight must not get that older read back.
|
|
485
|
+
it('reads again on load() while an ensureLoaded() read is in flight', async () => {
|
|
486
|
+
const api = stubApi({
|
|
487
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
488
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
489
|
+
})
|
|
490
|
+
const github = storeWithWorkspace()
|
|
491
|
+
await github.probe()
|
|
492
|
+
|
|
493
|
+
const ensured = github.ensureLoaded()
|
|
494
|
+
const reloaded = github.load()
|
|
495
|
+
await Promise.all([ensured, reloaded])
|
|
496
|
+
|
|
497
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
// A disconnect clears the projection outside a load. A probe that finds the workspace connected
|
|
501
|
+
// again (an App installed from another tab) must load it, not trust the earlier load.
|
|
502
|
+
it('loads again after a disconnect and a later probe that finds a connection', async () => {
|
|
503
|
+
const api = stubApi({
|
|
504
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
505
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
506
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
507
|
+
disconnectGitHub: vi.fn().mockResolvedValue(undefined),
|
|
508
|
+
})
|
|
509
|
+
const github = storeWithWorkspace()
|
|
510
|
+
|
|
511
|
+
await github.ensureLoaded()
|
|
512
|
+
await github.disconnect()
|
|
513
|
+
await github.probe()
|
|
514
|
+
await github.ensureLoaded()
|
|
515
|
+
|
|
516
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
517
|
+
expect(github.repos).toEqual([repo])
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// The reads behind `ensureLoaded()` and a later `load()` overlap, and the older one can finish
|
|
521
|
+
// last. Its result is older than what is already shown, so it does not replace it.
|
|
522
|
+
it('keeps the newer read when an older one finishes after it', async () => {
|
|
523
|
+
let finishOlderRead: (repos: GitHubRepo[]) => void = () => {}
|
|
524
|
+
const newer: GitHubRepo = { ...repo, githubId: 2, name: 'web' }
|
|
525
|
+
stubApi({
|
|
526
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
527
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
528
|
+
listGitHubRepos: vi
|
|
529
|
+
.fn()
|
|
530
|
+
.mockReturnValueOnce(
|
|
531
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
532
|
+
finishOlderRead = resolve
|
|
533
|
+
}),
|
|
534
|
+
)
|
|
535
|
+
.mockResolvedValueOnce([newer]),
|
|
536
|
+
})
|
|
537
|
+
const github = storeWithWorkspace()
|
|
538
|
+
await github.probe()
|
|
539
|
+
|
|
540
|
+
const ensured = github.ensureLoaded()
|
|
541
|
+
await github.load()
|
|
542
|
+
expect(github.repos).toEqual([newer])
|
|
543
|
+
// The older read is still running, but the newer one owns the flag and has finished.
|
|
544
|
+
expect(github.loading).toBe(false)
|
|
545
|
+
|
|
546
|
+
finishOlderRead([repo])
|
|
547
|
+
await ensured
|
|
548
|
+
|
|
549
|
+
expect(github.repos).toEqual([newer])
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
it('drops a read that finishes after a switch to another board', async () => {
|
|
553
|
+
let finishRead: (repos: GitHubRepo[]) => void = () => {}
|
|
554
|
+
stubApi({
|
|
555
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
556
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
557
|
+
listGitHubRepos: vi.fn().mockReturnValueOnce(
|
|
558
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
559
|
+
finishRead = resolve
|
|
560
|
+
}),
|
|
561
|
+
),
|
|
562
|
+
})
|
|
563
|
+
const github = storeWithWorkspace()
|
|
564
|
+
await github.probe()
|
|
565
|
+
|
|
566
|
+
const ensured = github.ensureLoaded()
|
|
567
|
+
useWorkspaceStore().workspaceId = 'ws-2'
|
|
568
|
+
github.reset()
|
|
569
|
+
finishRead([repo])
|
|
570
|
+
await ensured
|
|
571
|
+
|
|
572
|
+
expect(github.repos).toEqual([])
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
// In the usual order the older read finishes first. A newer read has started by then, so the older
|
|
576
|
+
// result is dropped, and the projection is not settled until the newer read finishes.
|
|
577
|
+
it('stays loading until the newest read finishes', async () => {
|
|
578
|
+
const finish: Array<(repos: GitHubRepo[]) => void> = []
|
|
579
|
+
const newer: GitHubRepo = { ...repo, githubId: 2, name: 'web' }
|
|
580
|
+
stubApi({
|
|
581
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
582
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
583
|
+
listGitHubRepos: vi.fn(
|
|
584
|
+
() =>
|
|
585
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
586
|
+
finish.push(resolve)
|
|
587
|
+
}),
|
|
588
|
+
),
|
|
589
|
+
})
|
|
590
|
+
const github = storeWithWorkspace()
|
|
591
|
+
await github.probe()
|
|
592
|
+
|
|
593
|
+
const ensured = github.ensureLoaded()
|
|
594
|
+
const reloaded = github.load()
|
|
595
|
+
finish[0]?.([repo])
|
|
596
|
+
await ensured
|
|
597
|
+
|
|
598
|
+
expect(github.repos).toEqual([])
|
|
599
|
+
expect(github.loading).toBe(true)
|
|
600
|
+
|
|
601
|
+
finish[1]?.([newer])
|
|
602
|
+
await reloaded
|
|
603
|
+
|
|
604
|
+
expect(github.repos).toEqual([newer])
|
|
605
|
+
expect(github.loading).toBe(false)
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// A disconnect clears the projection in the same workspace, so the board id cannot tell the
|
|
609
|
+
// read apart. A read that started before the disconnect must not put the old rows back.
|
|
610
|
+
it('drops a read that finishes after a disconnect', async () => {
|
|
611
|
+
let finishRead: (repos: GitHubRepo[]) => void = () => {}
|
|
612
|
+
stubApi({
|
|
613
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
614
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
615
|
+
listGitHubRepos: vi.fn().mockReturnValueOnce(
|
|
616
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
617
|
+
finishRead = resolve
|
|
618
|
+
}),
|
|
619
|
+
),
|
|
620
|
+
disconnectGitHub: vi.fn().mockResolvedValue(undefined),
|
|
621
|
+
})
|
|
622
|
+
const github = storeWithWorkspace()
|
|
623
|
+
await github.probe()
|
|
624
|
+
|
|
625
|
+
const ensured = github.ensureLoaded()
|
|
626
|
+
await github.disconnect()
|
|
627
|
+
// Nothing is left to wait for, so no spinner stays up for a read whose result is thrown away.
|
|
628
|
+
expect(github.loading).toBe(false)
|
|
629
|
+
finishRead([repo])
|
|
630
|
+
await ensured
|
|
631
|
+
|
|
632
|
+
expect(github.repos).toEqual([])
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
// `load()` reads nothing while the workspace is not connected, so it must not record the
|
|
636
|
+
// projection as loaded. A later probe that finds a connection must lead to a read.
|
|
637
|
+
it('loads after a connection appears when load() ran while not connected', async () => {
|
|
638
|
+
const api = stubApi({
|
|
639
|
+
getGitHubConnection: vi
|
|
640
|
+
.fn()
|
|
641
|
+
.mockResolvedValueOnce({ connection: null })
|
|
642
|
+
.mockResolvedValue({ connection: connection() }),
|
|
643
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
644
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
645
|
+
})
|
|
646
|
+
const github = storeWithWorkspace()
|
|
647
|
+
await github.probe()
|
|
648
|
+
|
|
649
|
+
await github.load()
|
|
650
|
+
await github.probe()
|
|
651
|
+
await github.ensureLoaded()
|
|
652
|
+
|
|
653
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
654
|
+
expect(github.repos).toEqual([repo])
|
|
655
|
+
})
|
|
656
|
+
|
|
657
|
+
// The App is uninstalled and installed again from another tab. The probe sees the new
|
|
658
|
+
// connection, and the earlier load belongs to the old one.
|
|
659
|
+
it('loads again when the probe finds a different connection', async () => {
|
|
660
|
+
const getGitHubConnection = vi
|
|
661
|
+
.fn()
|
|
662
|
+
.mockResolvedValueOnce({ connection: connection() })
|
|
663
|
+
.mockResolvedValueOnce({ connection: null })
|
|
664
|
+
.mockResolvedValue({ connection: connection({ installationId: 43, connectedAt: 2 }) })
|
|
665
|
+
const api = stubApi({
|
|
666
|
+
getGitHubConnection,
|
|
667
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
668
|
+
listGitHubRepos: vi.fn().mockResolvedValue([repo]),
|
|
669
|
+
})
|
|
670
|
+
const github = storeWithWorkspace()
|
|
671
|
+
|
|
672
|
+
await github.ensureLoaded()
|
|
673
|
+
await github.probe()
|
|
674
|
+
await github.probe()
|
|
675
|
+
await github.ensureLoaded()
|
|
676
|
+
|
|
677
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
// `refresh()` is the plain re-read for a surface opening while another read runs. It shares that
|
|
681
|
+
// read, where `load()` is the after-write door and starts its own.
|
|
682
|
+
it('shares an in-flight read on refresh() and starts a new one on load()', async () => {
|
|
683
|
+
const api = stubApi({
|
|
684
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
685
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
686
|
+
})
|
|
687
|
+
const github = storeWithWorkspace()
|
|
688
|
+
await github.probe()
|
|
689
|
+
|
|
690
|
+
await Promise.all([github.ensureLoaded(), github.refresh()])
|
|
691
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(1)
|
|
692
|
+
|
|
693
|
+
await Promise.all([github.ensureLoaded(), github.load()])
|
|
694
|
+
expect(api.listGitHubRepos).toHaveBeenCalledTimes(2)
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
// Saving the linked repos sets the projection directly. A read that started before the save
|
|
698
|
+
// must not put the earlier rows back, even if the reload that follows the save fails.
|
|
699
|
+
it('keeps the saved repos when a read that started before the save finishes afterwards', async () => {
|
|
700
|
+
let finishRead: (repos: GitHubRepo[]) => void = () => {}
|
|
701
|
+
const saved: GitHubRepo = { ...repo, githubId: 2, name: 'web' }
|
|
702
|
+
stubApi({
|
|
703
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
704
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
705
|
+
listGitHubRepos: vi
|
|
706
|
+
.fn()
|
|
707
|
+
.mockReturnValueOnce(
|
|
708
|
+
new Promise<GitHubRepo[]>((resolve) => {
|
|
709
|
+
finishRead = resolve
|
|
710
|
+
}),
|
|
711
|
+
)
|
|
712
|
+
.mockRejectedValueOnce(new Error('500')),
|
|
713
|
+
setGitHubLinkedRepos: vi.fn().mockResolvedValue([saved]),
|
|
714
|
+
})
|
|
715
|
+
const github = storeWithWorkspace()
|
|
716
|
+
await github.probe()
|
|
717
|
+
|
|
718
|
+
const ensured = github.ensureLoaded()
|
|
719
|
+
await expect(github.setLinkedRepos([2])).rejects.toThrow('500')
|
|
720
|
+
finishRead([repo])
|
|
721
|
+
await ensured
|
|
722
|
+
|
|
723
|
+
expect(github.repos).toEqual([saved])
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
it('keeps a pull request opened while a read was in flight', async () => {
|
|
727
|
+
let finishPulls: (pulls: GitHubPullRequest[]) => void = () => {}
|
|
728
|
+
const opened = { repoGithubId: 1, number: 7 } as GitHubPullRequest
|
|
729
|
+
stubApi({
|
|
730
|
+
getGitHubConnection: vi.fn().mockResolvedValue({ connection: connection() }),
|
|
731
|
+
listVcsConnectOptions: vi.fn().mockResolvedValue({ options: [] }),
|
|
732
|
+
listGitHubPullRequests: vi.fn().mockReturnValueOnce(
|
|
733
|
+
new Promise<GitHubPullRequest[]>((resolve) => {
|
|
734
|
+
finishPulls = resolve
|
|
735
|
+
}),
|
|
736
|
+
),
|
|
737
|
+
openGitHubPullRequest: vi.fn().mockResolvedValue(opened),
|
|
738
|
+
})
|
|
739
|
+
const github = storeWithWorkspace()
|
|
740
|
+
await github.probe()
|
|
741
|
+
|
|
742
|
+
const ensured = github.ensureLoaded()
|
|
743
|
+
await github.openPullRequest(1, {} as never)
|
|
744
|
+
finishPulls([])
|
|
745
|
+
await ensured
|
|
746
|
+
|
|
747
|
+
expect(github.pulls).toEqual([opened])
|
|
748
|
+
})
|
|
749
|
+
})
|
package/app/stores/github.ts
CHANGED
|
@@ -20,6 +20,7 @@ import { useServicesStore } from '~/stores/services'
|
|
|
20
20
|
import { pullKey, type GitHubStoreContext } from '~/stores/github/context'
|
|
21
21
|
import { createGitHubConnectionActions } from '~/stores/github/connection'
|
|
22
22
|
import { createGitHubProbe } from '~/stores/github/probe'
|
|
23
|
+
import { createGitHubProjectionLoad } from '~/stores/github/projectionLoad'
|
|
23
24
|
import { createGitHubRepoActions } from '~/stores/github/repoActions'
|
|
24
25
|
import { createVcsConnectActions, createVcsProviderViews } from '~/stores/github/vcsConnect'
|
|
25
26
|
|
|
@@ -138,23 +139,20 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
138
139
|
return branchWebUrl(providerOfRepo(repoGithubId), repoUrl(repoGithubId), branch)
|
|
139
140
|
}
|
|
140
141
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
loading.value = false
|
|
156
|
-
}
|
|
157
|
-
}
|
|
142
|
+
// The projection read and the record of whether it is loaded for this workspace.
|
|
143
|
+
const projection = createGitHubProjectionLoad({
|
|
144
|
+
api,
|
|
145
|
+
workspace,
|
|
146
|
+
connection,
|
|
147
|
+
repos,
|
|
148
|
+
pulls,
|
|
149
|
+
issues,
|
|
150
|
+
loading,
|
|
151
|
+
})
|
|
152
|
+
/** Reload the cached repos, pull requests and issues after a write. */
|
|
153
|
+
const load = projection.load
|
|
154
|
+
/** Reload them on demand, sharing a read already in flight. */
|
|
155
|
+
const refresh = projection.refresh
|
|
158
156
|
|
|
159
157
|
/** Full file listing per repo (recursive tree), cached by GitHub numeric id. */
|
|
160
158
|
const repoFiles = ref<Record<number, RepoTreeEntry[]>>({})
|
|
@@ -184,6 +182,8 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
184
182
|
syncing,
|
|
185
183
|
connected,
|
|
186
184
|
load,
|
|
185
|
+
markWritten: projection.markWritten,
|
|
186
|
+
clearProjection: projection.clearProjection,
|
|
187
187
|
}
|
|
188
188
|
// The board-load probe (integration availability + the bound connection + the connect options +
|
|
189
189
|
// the credential check). Built from the context rather than inline, so this setup stays within
|
|
@@ -193,11 +193,11 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
193
193
|
/**
|
|
194
194
|
* Ensure the projection (repos/PRs/issues) is loaded at least once — for views
|
|
195
195
|
* that need it without opening the GitHub panel (e.g. the inspector's repo link).
|
|
196
|
-
* Probes the integration first if it hasn't been yet.
|
|
196
|
+
* Probes the integration first if it hasn't been yet. Concurrent callers share one read.
|
|
197
197
|
*/
|
|
198
198
|
async function ensureLoaded() {
|
|
199
199
|
if (available.value === null) await probe()
|
|
200
|
-
|
|
200
|
+
await projection.ensureLoaded()
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
const connectionActions = createGitHubConnectionActions(context)
|
|
@@ -213,16 +213,14 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
213
213
|
* land. `available` returns to `null` (unknown) so the onboarding gate re-resolves.
|
|
214
214
|
*/
|
|
215
215
|
function reset() {
|
|
216
|
+
projection.clearProjection()
|
|
216
217
|
available.value = null
|
|
217
218
|
unavailableCause.value = null
|
|
218
219
|
connection.value = null
|
|
219
220
|
connectOptions.value = []
|
|
220
221
|
patCheck.value = null
|
|
221
222
|
installations.value = []
|
|
222
|
-
repos.value = []
|
|
223
223
|
availableRepos.value = []
|
|
224
|
-
pulls.value = []
|
|
225
|
-
issues.value = []
|
|
226
224
|
branches.value = {}
|
|
227
225
|
repoFiles.value = {}
|
|
228
226
|
}
|
|
@@ -259,6 +257,7 @@ export const useGitHubStore = defineStore('github', () => {
|
|
|
259
257
|
probe,
|
|
260
258
|
ensureProbed,
|
|
261
259
|
load,
|
|
260
|
+
refresh,
|
|
262
261
|
ensureLoaded,
|
|
263
262
|
repoFiles,
|
|
264
263
|
...connectionActions,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createUiModals } from '~/stores/ui/modals'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The pipeline builder and the model configuration never stack. The builder is a modal Reka
|
|
6
|
+
* slideover that holds `pointer-events: none` on <body> until its exit animation ends, so the
|
|
7
|
+
* model configuration (the app's own takeover) only shows once the builder reports it has left.
|
|
8
|
+
*/
|
|
9
|
+
describe('pipeline builder to model configuration handoff', () => {
|
|
10
|
+
it('shows the model configuration only after the builder has left, and reopens the builder on close', () => {
|
|
11
|
+
const ui = createUiModals()
|
|
12
|
+
ui.openBuilder()
|
|
13
|
+
|
|
14
|
+
ui.openModelConfig()
|
|
15
|
+
expect(ui.builderOpen.value).toBe(false)
|
|
16
|
+
expect(ui.modelConfigOpen.value).toBe(false)
|
|
17
|
+
|
|
18
|
+
ui.builderLeft()
|
|
19
|
+
expect(ui.modelConfigOpen.value).toBe(true)
|
|
20
|
+
|
|
21
|
+
ui.closeModelConfig()
|
|
22
|
+
expect(ui.modelConfigOpen.value).toBe(false)
|
|
23
|
+
expect(ui.builderOpen.value).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('opens at once when the builder is not open, and ignores a stray builder exit', () => {
|
|
27
|
+
const ui = createUiModals()
|
|
28
|
+
ui.openModelConfig()
|
|
29
|
+
expect(ui.modelConfigOpen.value).toBe(true)
|
|
30
|
+
|
|
31
|
+
ui.closeModelConfig()
|
|
32
|
+
expect(ui.builderOpen.value).toBe(false)
|
|
33
|
+
|
|
34
|
+
ui.builderLeft()
|
|
35
|
+
expect(ui.modelConfigOpen.value).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('returns to the builder once, not on every later close', () => {
|
|
39
|
+
const ui = createUiModals()
|
|
40
|
+
ui.openBuilder()
|
|
41
|
+
ui.openModelConfig()
|
|
42
|
+
ui.builderLeft()
|
|
43
|
+
ui.closeModelConfig()
|
|
44
|
+
ui.builderOpen.value = false
|
|
45
|
+
|
|
46
|
+
ui.openModelConfig()
|
|
47
|
+
ui.closeModelConfig()
|
|
48
|
+
expect(ui.builderOpen.value).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('opening the builder another way closes the model configuration and drops the return', () => {
|
|
52
|
+
const ui = createUiModals()
|
|
53
|
+
ui.openBuilder()
|
|
54
|
+
ui.openModelConfig()
|
|
55
|
+
ui.builderLeft()
|
|
56
|
+
|
|
57
|
+
ui.openBuilder()
|
|
58
|
+
expect(ui.modelConfigOpen.value).toBe(false)
|
|
59
|
+
expect(ui.builderOpen.value).toBe(true)
|
|
60
|
+
|
|
61
|
+
ui.builderOpen.value = false
|
|
62
|
+
ui.openModelConfig()
|
|
63
|
+
ui.closeModelConfig()
|
|
64
|
+
expect(ui.builderOpen.value).toBe(false)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('drops a pending show when the builder is reopened before it has left', () => {
|
|
68
|
+
const ui = createUiModals()
|
|
69
|
+
ui.openBuilder()
|
|
70
|
+
ui.openModelConfig()
|
|
71
|
+
ui.openBuilder()
|
|
72
|
+
|
|
73
|
+
ui.builderLeft()
|
|
74
|
+
expect(ui.modelConfigOpen.value).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('reports a return to the builder once, so it can skip its reloads', () => {
|
|
78
|
+
const ui = createUiModals()
|
|
79
|
+
ui.openBuilder()
|
|
80
|
+
expect(ui.consumeBuilderReturn()).toBe(false)
|
|
81
|
+
|
|
82
|
+
ui.openModelConfig()
|
|
83
|
+
ui.builderLeft()
|
|
84
|
+
ui.closeModelConfig()
|
|
85
|
+
expect(ui.consumeBuilderReturn()).toBe(true)
|
|
86
|
+
expect(ui.consumeBuilderReturn()).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -182,6 +182,80 @@ function createHealthAdvisoryModals() {
|
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The pipeline builder and the model configuration never stack. Opening the model configuration
|
|
187
|
+
* from the builder closes the builder, shows the model configuration once the builder has
|
|
188
|
+
* finished leaving, and brings the builder back when the model configuration closes.
|
|
189
|
+
*
|
|
190
|
+
* The builder is a `USlideover`, a modal Reka layer: while it is mounted, Reka sets
|
|
191
|
+
* `pointer-events: none` on `<body>`, and lifts it only when the exit animation ends. The model
|
|
192
|
+
* configuration is the app's own takeover, teleported to `<body>` and not a Reka layer, so shown
|
|
193
|
+
* over the builder (or during its exit) every click landed on the builder behind it. Waiting for
|
|
194
|
+
* the exit (`builderLeft`, from the builder's `after:leave`) fixes that without an escape hatch on
|
|
195
|
+
* the panel, which would also defeat the pointer lock of the panel's OWN dropdowns. The fix lives
|
|
196
|
+
* here, not at the builder's "Configure models" button, because the "choose a model" action on a
|
|
197
|
+
* run-admission failure toast (`preset_unsatisfiable`, `model_policy_blocked`) also opens the
|
|
198
|
+
* model configuration.
|
|
199
|
+
*
|
|
200
|
+
* This owns BOTH openers: `openBuilder` closes the model configuration and drops a pending return,
|
|
201
|
+
* so a builder the user opened another way is not followed by a stale reopen.
|
|
202
|
+
*
|
|
203
|
+
* Closing the builder unmounts its body. The draft pipeline is in the pipelines store and survives,
|
|
204
|
+
* but component-local state does not: a half-typed output size in the binary-output picker, the
|
|
205
|
+
* rejected media types and references it reports, and the guarded-gate roster. The builder
|
|
206
|
+
* reloads nothing on the way back (`consumeBuilderReturn`), since nothing it loads changes while
|
|
207
|
+
* the model configuration is open.
|
|
208
|
+
*
|
|
209
|
+
* Other takeovers (reports, kaizen, operator dashboard) opened over a modal Reka layer have the
|
|
210
|
+
* same unclickable-takeover defect and are not handled here.
|
|
211
|
+
*/
|
|
212
|
+
function createBuilderModelConfigHandoff(
|
|
213
|
+
misc: Pick<ReturnType<typeof createMiscModals>, 'builderOpen'>,
|
|
214
|
+
panels: Pick<ReturnType<typeof createIntegrationPanelModals>, 'modelConfigOpen'>,
|
|
215
|
+
) {
|
|
216
|
+
const returnToBuilder = ref(false)
|
|
217
|
+
const showAfterBuilderExit = ref(false)
|
|
218
|
+
const returningToBuilder = ref(false)
|
|
219
|
+
|
|
220
|
+
function openModelConfig() {
|
|
221
|
+
if (showAfterBuilderExit.value) return
|
|
222
|
+
if (misc.builderOpen.value) {
|
|
223
|
+
misc.builderOpen.value = false
|
|
224
|
+
returnToBuilder.value = true
|
|
225
|
+
showAfterBuilderExit.value = true
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
panels.modelConfigOpen.value = true
|
|
229
|
+
}
|
|
230
|
+
function builderLeft() {
|
|
231
|
+
if (!showAfterBuilderExit.value) return
|
|
232
|
+
showAfterBuilderExit.value = false
|
|
233
|
+
panels.modelConfigOpen.value = true
|
|
234
|
+
}
|
|
235
|
+
function closeModelConfig() {
|
|
236
|
+
panels.modelConfigOpen.value = false
|
|
237
|
+
if (!returnToBuilder.value) return
|
|
238
|
+
returnToBuilder.value = false
|
|
239
|
+
returningToBuilder.value = true
|
|
240
|
+
misc.builderOpen.value = true
|
|
241
|
+
}
|
|
242
|
+
function openBuilder() {
|
|
243
|
+
panels.modelConfigOpen.value = false
|
|
244
|
+
showAfterBuilderExit.value = false
|
|
245
|
+
returnToBuilder.value = false
|
|
246
|
+
returningToBuilder.value = false
|
|
247
|
+
misc.builderOpen.value = true
|
|
248
|
+
}
|
|
249
|
+
/** True once per return from the model configuration, so the builder can skip its reloads. */
|
|
250
|
+
function consumeBuilderReturn(): boolean {
|
|
251
|
+
const returning = returningToBuilder.value
|
|
252
|
+
returningToBuilder.value = false
|
|
253
|
+
return returning
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { openModelConfig, closeModelConfig, openBuilder, builderLeft, consumeBuilderReturn }
|
|
257
|
+
}
|
|
258
|
+
|
|
185
259
|
/**
|
|
186
260
|
* Small standalone surfaces with no hub relationship: the pipeline builder and the
|
|
187
261
|
* decision-wait window.
|
|
@@ -190,9 +264,6 @@ function createMiscModals() {
|
|
|
190
264
|
const builderOpen = ref(false)
|
|
191
265
|
const decisionContext = ref<{ instanceId: string; decisionId: string } | null>(null)
|
|
192
266
|
|
|
193
|
-
function openBuilder() {
|
|
194
|
-
builderOpen.value = true
|
|
195
|
-
}
|
|
196
267
|
function openDecision(instanceId: string, decisionId: string) {
|
|
197
268
|
decisionContext.value = { instanceId, decisionId }
|
|
198
269
|
}
|
|
@@ -200,7 +271,7 @@ function createMiscModals() {
|
|
|
200
271
|
decisionContext.value = null
|
|
201
272
|
}
|
|
202
273
|
|
|
203
|
-
return { builderOpen, decisionContext,
|
|
274
|
+
return { builderOpen, decisionContext, openDecision, closeDecision }
|
|
204
275
|
}
|
|
205
276
|
|
|
206
277
|
/** What the guided review window opens on: a session, a pull request, or neither (the picker). */
|
|
@@ -590,6 +661,7 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
590
661
|
// API access tokens: the workspace's inbound public-API keys external systems present to
|
|
591
662
|
// the `/api/v1` surface. Opened from the Integrations hub.
|
|
592
663
|
const apiTokensOpen = ref(false)
|
|
664
|
+
// Opened and closed by `createBuilderModelConfigHandoff`, which hands the pipeline builder over.
|
|
593
665
|
const modelConfigOpen = ref(false)
|
|
594
666
|
// LLM-vendor subscription credentials (the token pool powering the Claude Code
|
|
595
667
|
// / Codex harnesses). `vendorCredentialsTab` lets a caller deep-link to one tab —
|
|
@@ -651,12 +723,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
651
723
|
function closeApiTokens() {
|
|
652
724
|
apiTokensOpen.value = false
|
|
653
725
|
}
|
|
654
|
-
function openModelConfig() {
|
|
655
|
-
modelConfigOpen.value = true
|
|
656
|
-
}
|
|
657
|
-
function closeModelConfig() {
|
|
658
|
-
modelConfigOpen.value = false
|
|
659
|
-
}
|
|
660
726
|
function openVendorCredentials(tab = 'pool') {
|
|
661
727
|
resetHubReturn()
|
|
662
728
|
vendorCredentialsTab.value = tab
|
|
@@ -718,8 +784,6 @@ function createIntegrationPanelModals(resetHubReturn: ResetHubReturn) {
|
|
|
718
784
|
closeOperatorDashboard,
|
|
719
785
|
openApiTokens,
|
|
720
786
|
closeApiTokens,
|
|
721
|
-
openModelConfig,
|
|
722
|
-
closeModelConfig,
|
|
723
787
|
openVendorCredentials,
|
|
724
788
|
setVendorCredentialsTab,
|
|
725
789
|
closeVendorCredentials,
|
|
@@ -1223,6 +1287,7 @@ export function createUiModals() {
|
|
|
1223
1287
|
const settings = createSettingsModals(resetHubReturn)
|
|
1224
1288
|
const infra = createInfraModals(resetHubReturn)
|
|
1225
1289
|
const ai = createAiOnboardingModals()
|
|
1290
|
+
const builderHandoff = createBuilderModelConfigHandoff(misc, panels)
|
|
1226
1291
|
|
|
1227
1292
|
return {
|
|
1228
1293
|
integrationsOpen,
|
|
@@ -1249,5 +1314,6 @@ export function createUiModals() {
|
|
|
1249
1314
|
...settings,
|
|
1250
1315
|
...infra,
|
|
1251
1316
|
...ai,
|
|
1317
|
+
...builderHandoff,
|
|
1252
1318
|
}
|
|
1253
1319
|
}
|
package/i18n/locales/de.json
CHANGED
|
@@ -3712,6 +3712,7 @@
|
|
|
3712
3712
|
},
|
|
3713
3713
|
"errors": {
|
|
3714
3714
|
"listInstallations": "GitHub-Installationen konnten nicht aufgelistet werden",
|
|
3715
|
+
"loadProjection": "Repositories, Pull Requests und Issues konnten nicht geladen werden",
|
|
3715
3716
|
"startInstall": "Die Installation der GitHub App konnte nicht gestartet werden",
|
|
3716
3717
|
"connect": "Verbindung nicht möglich"
|
|
3717
3718
|
}
|
package/i18n/locales/en.json
CHANGED
|
@@ -4720,6 +4720,7 @@
|
|
|
4720
4720
|
},
|
|
4721
4721
|
"errors": {
|
|
4722
4722
|
"listInstallations": "Could not list GitHub installations",
|
|
4723
|
+
"loadProjection": "Could not load repositories, pull requests and issues",
|
|
4723
4724
|
"startInstall": "Could not start the GitHub App install",
|
|
4724
4725
|
"connect": "Could not connect"
|
|
4725
4726
|
}
|
package/i18n/locales/es.json
CHANGED
|
@@ -4544,6 +4544,7 @@
|
|
|
4544
4544
|
},
|
|
4545
4545
|
"errors": {
|
|
4546
4546
|
"listInstallations": "No se pudieron listar las instalaciones de GitHub",
|
|
4547
|
+
"loadProjection": "No se pudieron cargar los repositorios, las pull requests y las issues",
|
|
4547
4548
|
"startInstall": "No se pudo iniciar la instalación de la GitHub App",
|
|
4548
4549
|
"connect": "No se pudo conectar"
|
|
4549
4550
|
}
|
package/i18n/locales/fr.json
CHANGED
|
@@ -4544,6 +4544,7 @@
|
|
|
4544
4544
|
},
|
|
4545
4545
|
"errors": {
|
|
4546
4546
|
"listInstallations": "Impossible de lister les installations GitHub",
|
|
4547
|
+
"loadProjection": "Impossible de charger les dépôts, les pull requests et les issues",
|
|
4547
4548
|
"startInstall": "Impossible de démarrer l'installation de la GitHub App",
|
|
4548
4549
|
"connect": "Connexion impossible"
|
|
4549
4550
|
}
|
package/i18n/locales/he.json
CHANGED
|
@@ -4544,6 +4544,7 @@
|
|
|
4544
4544
|
},
|
|
4545
4545
|
"errors": {
|
|
4546
4546
|
"listInstallations": "לא ניתן היה לרשום את התקנות GitHub",
|
|
4547
|
+
"loadProjection": "לא ניתן היה לטעון מאגרים, בקשות משיכה ובעיות",
|
|
4547
4548
|
"startInstall": "לא ניתן היה להתחיל את התקנת אפליקציית GitHub",
|
|
4548
4549
|
"connect": "לא ניתן היה להתחבר"
|
|
4549
4550
|
}
|
package/i18n/locales/it.json
CHANGED
|
@@ -3712,6 +3712,7 @@
|
|
|
3712
3712
|
},
|
|
3713
3713
|
"errors": {
|
|
3714
3714
|
"listInstallations": "Impossibile elencare le installazioni GitHub",
|
|
3715
|
+
"loadProjection": "Impossibile caricare repository, pull request e issue",
|
|
3715
3716
|
"startInstall": "Impossibile avviare l'installazione della GitHub App",
|
|
3716
3717
|
"connect": "Impossibile collegare"
|
|
3717
3718
|
}
|
package/i18n/locales/ja.json
CHANGED
package/i18n/locales/pl.json
CHANGED
|
@@ -4544,6 +4544,7 @@
|
|
|
4544
4544
|
},
|
|
4545
4545
|
"errors": {
|
|
4546
4546
|
"listInstallations": "Nie udało się wyświetlić instalacji GitHub",
|
|
4547
|
+
"loadProjection": "Nie udało się wczytać repozytoriów, pull requestów i zgłoszeń",
|
|
4547
4548
|
"startInstall": "Nie udało się rozpocząć instalacji aplikacji GitHub",
|
|
4548
4549
|
"connect": "Nie udało się połączyć"
|
|
4549
4550
|
}
|
package/i18n/locales/tr.json
CHANGED
|
@@ -4544,6 +4544,7 @@
|
|
|
4544
4544
|
},
|
|
4545
4545
|
"errors": {
|
|
4546
4546
|
"listInstallations": "GitHub yüklemeleri listelenemedi",
|
|
4547
|
+
"loadProjection": "Depolar, çekme istekleri ve sorunlar yüklenemedi",
|
|
4547
4548
|
"startInstall": "GitHub App yüklemesi başlatılamadı",
|
|
4548
4549
|
"connect": "Bağlanamadı"
|
|
4549
4550
|
}
|
package/i18n/locales/uk.json
CHANGED
|
@@ -4544,6 +4544,7 @@
|
|
|
4544
4544
|
},
|
|
4545
4545
|
"errors": {
|
|
4546
4546
|
"listInstallations": "Не вдалося отримати список встановлень GitHub",
|
|
4547
|
+
"loadProjection": "Не вдалося завантажити репозиторії, pull request-и та issue",
|
|
4547
4548
|
"startInstall": "Не вдалося розпочати встановлення застосунку GitHub",
|
|
4548
4549
|
"connect": "Не вдалося підключити"
|
|
4549
4550
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.315.
|
|
3
|
+
"version": "0.315.6",
|
|
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",
|