@cat-factory/app 0.315.3 → 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.
Files changed (79) hide show
  1. package/README.md +25 -4
  2. package/app/components/assistant/AssistantModal.vue +3 -2
  3. package/app/components/board/AddTaskModal.vue +3 -2
  4. package/app/components/board/CreateInitiativeModal.vue +3 -2
  5. package/app/components/board/RecurringPipelineModal.vue +3 -2
  6. package/app/components/board/ReviewFrictionDialog.vue +4 -3
  7. package/app/components/bootstrap/AdoptionReviewModal.vue +2 -0
  8. package/app/components/bootstrap/BootstrapModal.vue +4 -3
  9. package/app/components/common/AppModal.vue +50 -0
  10. package/app/components/common/ConfirmDialog.vue +5 -3
  11. package/app/components/common/KeyboardShortcutsHelp.vue +6 -4
  12. package/app/components/documents/DocumentImportModal.vue +3 -2
  13. package/app/components/documents/DocumentSourceConnectModal.vue +7 -2
  14. package/app/components/documents/DocumentTemplatesModal.vue +3 -2
  15. package/app/components/documents/SpawnPreviewModal.vue +3 -2
  16. package/app/components/documents/StartFromDesignModal.vue +3 -2
  17. package/app/components/environments/EnvironmentSetupWizard.vue +2 -0
  18. package/app/components/foundational/FoundationalServicePanel.vue +2 -0
  19. package/app/components/fragments/FragmentLibraryPanel.vue +2 -0
  20. package/app/components/github/AddServiceFromRepoModal.vue +5 -4
  21. package/app/components/github/GitHubPanel.vue +4 -3
  22. package/app/components/layout/BoardSwitcher.vue +7 -2
  23. package/app/components/layout/CommandBar.vue +3 -2
  24. package/app/components/layout/IntegrationsHub.vue +3 -6
  25. package/app/components/layout/ModelProvidersHub.vue +3 -6
  26. package/app/components/layout/PersonalSetupModal.vue +3 -6
  27. package/app/components/layout/RolePrompt.vue +3 -2
  28. package/app/components/notifications/NotificationSettingsPanel.vue +3 -6
  29. package/app/components/panels/DecisionModal.vue +3 -2
  30. package/app/components/panels/InspectorPanel.vue +6 -1
  31. package/app/components/panels/inspector/ServiceTestConfig.vue +6 -4
  32. package/app/components/pipeline/AgentPromptEditor.vue +2 -0
  33. package/app/components/pipeline/PipelineBuilder.vue +11 -2
  34. package/app/components/pipeline/PipelineHealthModal.vue +3 -2
  35. package/app/components/providers/AiPresetMismatchDialog.vue +3 -6
  36. package/app/components/providers/AiProviderOnboardingModal.vue +3 -6
  37. package/app/components/providers/PersonalCredentialModal.vue +3 -2
  38. package/app/components/providers/VendorCredentialsModal.vue +3 -6
  39. package/app/components/queue/DescribeWorkModal.vue +3 -2
  40. package/app/components/sandbox/SandboxPanel.vue +2 -0
  41. package/app/components/settings/AccountSettingsPanel.vue +1 -0
  42. package/app/components/settings/ApiTokensPanel.vue +3 -2
  43. package/app/components/settings/InfrastructureWindow.vue +3 -6
  44. package/app/components/settings/LocalModelEndpointsPanel.vue +3 -6
  45. package/app/components/settings/ModelConfigurationPanel.vue +9 -1
  46. package/app/components/settings/ModelPresetHealthModal.vue +3 -2
  47. package/app/components/settings/ObservabilityConnectionPanel.vue +3 -6
  48. package/app/components/settings/OpenRouterCatalogPanel.vue +3 -2
  49. package/app/components/settings/RiskPolicyHealthModal.vue +3 -2
  50. package/app/components/settings/UserSecretsSection.vue +3 -6
  51. package/app/components/settings/WorkspaceSettingsPanel.vue +1 -0
  52. package/app/components/setup/SetupChecklistPanel.vue +3 -2
  53. package/app/components/slack/SlackPanel.vue +3 -2
  54. package/app/components/tasks/BugHuntModal.vue +2 -0
  55. package/app/components/tasks/TaskImportModal.vue +3 -2
  56. package/app/components/tasks/TaskSourceConnectModal.vue +3 -2
  57. package/app/components/theme/ThemeImportModal.vue +4 -2
  58. package/app/components/tutorial/TutorialCatalogue.vue +4 -3
  59. package/app/components/tutorial/TutorialPrompt.vue +3 -2
  60. package/app/stores/github/connection.ts +2 -4
  61. package/app/stores/github/context.ts +5 -1
  62. package/app/stores/github/projectionLoad.ts +127 -0
  63. package/app/stores/github/repoActions.ts +7 -2
  64. package/app/stores/github.spec.ts +355 -1
  65. package/app/stores/github.ts +21 -22
  66. package/app/stores/ui/builderModelConfigHandoff.spec.ts +88 -0
  67. package/app/stores/ui/modals.ts +78 -12
  68. package/app/utils/overlaySize.ts +22 -0
  69. package/i18n/locales/de.json +1 -0
  70. package/i18n/locales/en.json +1 -0
  71. package/i18n/locales/es.json +1 -0
  72. package/i18n/locales/fr.json +1 -0
  73. package/i18n/locales/he.json +1 -0
  74. package/i18n/locales/it.json +1 -0
  75. package/i18n/locales/ja.json +1 -0
  76. package/i18n/locales/pl.json +1 -0
  77. package/i18n/locales/tr.json +1 -0
  78. package/i18n/locales/uk.json +1 -0
  79. package/package.json +1 -1
@@ -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 { GitHubConnection, GitHubPatCheck, GitHubRepo, VcsConnectOption } from '~/types/domain'
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
+ })
@@ -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
- /** Load the cached repos, pull requests and issues for the workspace. */
142
- async function load() {
143
- if (!connected.value) return
144
- loading.value = true
145
- try {
146
- const [r, p, i] = await Promise.all([
147
- api.listGitHubRepos(workspace.requireId()),
148
- api.listGitHubPullRequests(workspace.requireId()),
149
- api.listGitHubIssues(workspace.requireId()),
150
- ])
151
- repos.value = r
152
- pulls.value = p
153
- issues.value = i
154
- } finally {
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
- if (connected.value && repos.value.length === 0) await load()
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
+ })
@@ -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, openBuilder, openDecision, closeDecision }
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
  }