@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,4 +1,5 @@
1
1
  <script setup lang="ts">
2
+ import AppModal from '~/components/common/AppModal.vue'
2
3
  // Startup advisory for built-in merge presets that drifted from the catalog. Opened once per
3
4
  // session from the board page when `useRiskPolicyHealth` reports any issue. Lists:
4
5
  // • new built-in presets the workspace doesn't have yet (ADD them);
@@ -48,7 +49,7 @@ const reseedableCount = computed(
48
49
  </script>
49
50
 
50
51
  <template>
51
- <UModal v-model:open="open" :title="t('riskPolicy.health.title')" :ui="{ content: 'max-w-2xl' }">
52
+ <AppModal size="form" v-model:open="open" :title="t('riskPolicy.health.title')">
52
53
  <template #body>
53
54
  <div v-if="!hasIssues" class="py-6 text-center text-sm text-muted">
54
55
  <UIcon name="i-lucide-check-circle-2" class="mx-auto mb-2 h-8 w-8 text-app-success-400" />
@@ -156,5 +157,5 @@ const reseedableCount = computed(
156
157
  </UButton>
157
158
  </div>
158
159
  </template>
159
- </UModal>
160
+ </AppModal>
160
161
  </template>
@@ -11,6 +11,7 @@ import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
11
11
  import SecretInput from '~/components/common/SecretInput.vue'
12
12
  import ConnectionWarnings from '~/components/settings/ConnectionWarnings.vue'
13
13
  import SectionLabel from '~/components/common/SectionLabel.vue'
14
+ import AppModal from '~/components/common/AppModal.vue'
14
15
 
15
16
  const { t } = useI18n()
16
17
  const ui = useUiStore()
@@ -142,11 +143,7 @@ async function remove() {
142
143
  </script>
143
144
 
144
145
  <template>
145
- <UModal
146
- v-model:open="open"
147
- :title="t('settings.userSecrets.title')"
148
- :ui="{ content: 'max-w-xl' }"
149
- >
146
+ <AppModal size="form" v-model:open="open" :title="t('settings.userSecrets.title')">
150
147
  <template #title>
151
148
  <IntegrationBackTitle :title="t('settings.userSecrets.title')" @back="back" />
152
149
  </template>
@@ -247,5 +244,5 @@ async function remove() {
247
244
  </div>
248
245
  </div>
249
246
  </template>
250
- </UModal>
247
+ </AppModal>
251
248
  </template>
@@ -328,6 +328,7 @@ async function save() {
328
328
  </script>
329
329
 
330
330
  <template>
331
+ <!-- workbench-width-ok: tabbed workspace settings (768px). Its shell waits on #2359. -->
331
332
  <UModal
332
333
  v-model:open="open"
333
334
  :title="t('settings.workspaceSettings.title')"
@@ -3,6 +3,7 @@ import type { SetupRow, SetupRowCause, SetupRowId, SetupRowStatus } from '~/util
3
3
  import type { SetupRemedy } from '~/utils/setupAudience'
4
4
  import SectionLabel from '~/components/common/SectionLabel.vue'
5
5
  import Spinner from '~/components/common/Spinner.vue'
6
+ import AppModal from '~/components/common/AppModal.vue'
6
7
 
7
8
  /**
8
9
  * The setup checklist: every prerequisite for "a task can run and merge", its state, and one
@@ -210,7 +211,7 @@ const also = computed(() =>
210
211
  </script>
211
212
 
212
213
  <template>
213
- <UModal v-model:open="open" :title="t('setup.title')" :ui="{ content: 'sm:max-w-3xl' }">
214
+ <AppModal size="wide" v-model:open="open" :title="t('setup.title')">
214
215
  <template #title>
215
216
  <span class="flex items-baseline gap-3">
216
217
  {{ t('setup.title') }}
@@ -336,5 +337,5 @@ const also = computed(() =>
336
337
  </ul>
337
338
  </div>
338
339
  </template>
339
- </UModal>
340
+ </AppModal>
340
341
  </template>
@@ -17,6 +17,7 @@ import {
17
17
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
18
18
  import SecretInput from '~/components/common/SecretInput.vue'
19
19
  import SectionLabel from '~/components/common/SectionLabel.vue'
20
+ import AppModal from '~/components/common/AppModal.vue'
20
21
 
21
22
  const ui = useUiStore()
22
23
  const slack = useSlackStore()
@@ -202,7 +203,7 @@ async function saveMapping() {
202
203
  </script>
203
204
 
204
205
  <template>
205
- <UModal v-model:open="open" :title="t('slack.panel.title')" :ui="{ content: 'max-w-2xl' }">
206
+ <AppModal size="form" v-model:open="open" :title="t('slack.panel.title')">
206
207
  <template #title>
207
208
  <IntegrationBackTitle :title="t('slack.panel.title')" @back="back" />
208
209
  </template>
@@ -372,5 +373,5 @@ async function saveMapping() {
372
373
  </template>
373
374
  </div>
374
375
  </template>
375
- </UModal>
376
+ </AppModal>
376
377
  </template>
@@ -335,6 +335,8 @@ const STATUS_KEYS: Record<BugHuntAnalysisStatus, string> = {
335
335
  </script>
336
336
 
337
337
  <template>
338
+ <!-- workbench-width-ok: a ranked list of tracker bugs with scan controls (768px). Its shell
339
+ waits on #2359. -->
338
340
  <UModal v-model:open="open" :title="t('bugHunt.title')" :ui="{ content: 'max-w-3xl' }">
339
341
  <template #title>
340
342
  <IntegrationBackTitle :title="t('bugHunt.title')" @back="back" />
@@ -19,6 +19,7 @@ import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
19
19
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
20
20
  import SectionLabel from '~/components/common/SectionLabel.vue'
21
21
  import EmptyState from '~/components/common/EmptyState.vue'
22
+ import AppModal from '~/components/common/AppModal.vue'
22
23
 
23
24
  const { t } = useI18n()
24
25
  const ui = useUiStore()
@@ -120,7 +121,7 @@ async function doSpawnEpic() {
120
121
  </script>
121
122
 
122
123
  <template>
123
- <UModal v-model:open="open" :title="title">
124
+ <AppModal size="form" v-model:open="open" :title="title">
124
125
  <template #title>
125
126
  <IntegrationBackTitle :title="title" @back="back" />
126
127
  </template>
@@ -215,5 +216,5 @@ async function doSpawnEpic() {
215
216
  </div>
216
217
  </div>
217
218
  </template>
218
- </UModal>
219
+ </AppModal>
219
220
  </template>
@@ -12,6 +12,7 @@
12
12
  // available (Jira connected / the GitHub App installed) — there is nothing to offer
13
13
  // before that.
14
14
  import IntegrationBackTitle from '~/components/layout/IntegrationBackTitle.vue'
15
+ import AppModal from '~/components/common/AppModal.vue'
15
16
 
16
17
  const { t } = useI18n()
17
18
  const ui = useUiStore()
@@ -116,7 +117,7 @@ async function toggleEnabled(enabled: boolean) {
116
117
  </script>
117
118
 
118
119
  <template>
119
- <UModal v-model:open="open" :title="descriptor?.label ?? t('tasks.connect.title')">
120
+ <AppModal size="form" v-model:open="open" :title="descriptor?.label ?? t('tasks.connect.title')">
120
121
  <template #title>
121
122
  <IntegrationBackTitle :title="descriptor?.label ?? t('tasks.connect.title')" @back="back" />
122
123
  </template>
@@ -212,5 +213,5 @@ async function toggleEnabled(enabled: boolean) {
212
213
  </div>
213
214
  </div>
214
215
  </template>
215
- </UModal>
216
+ </AppModal>
216
217
  </template>
@@ -2,6 +2,7 @@
2
2
  import { computed, ref, watch } from 'vue'
3
3
  import { useThemeStore } from '~/stores/theme'
4
4
  import { decodeThemeLink, type ThemeLinkDecodeResult } from '~/utils/theme/link'
5
+ import AppModal from '~/components/common/AppModal.vue'
5
6
 
6
7
  // Import a theme built in the Nuxt UI theme editor (https://ui.nuxt.com/theme): the user pastes
7
8
  // the editor's share link (or the raw document JSON), names it, and the theme is stored in this
@@ -60,7 +61,8 @@ async function submit() {
60
61
  </script>
61
62
 
62
63
  <template>
63
- <UModal
64
+ <AppModal
65
+ size="prompt"
64
66
  v-model:open="open"
65
67
  :title="t('appearance.import.title')"
66
68
  :description="t('appearance.import.description')"
@@ -111,5 +113,5 @@ async function submit() {
111
113
  </div>
112
114
  </UForm>
113
115
  </template>
114
- </UModal>
116
+ </AppModal>
115
117
  </template>
@@ -14,6 +14,7 @@ import { buildCatalogueRows, summarizeProgress } from './TutorialCatalogue.logic
14
14
  import type { TutorialCatalogueRow } from './TutorialCatalogue.logic'
15
15
  import { TUTORIAL_ACTION_KEYS, TUTORIAL_STATUS_KEYS } from '~/utils/tutorial'
16
16
  import EmptyState from '~/components/common/EmptyState.vue'
17
+ import AppModal from '~/components/common/AppModal.vue'
17
18
 
18
19
  const { t } = useI18n()
19
20
  const tutorial = useTutorialStore()
@@ -51,11 +52,11 @@ const statusColor = (row: TutorialCatalogueRow) =>
51
52
  </script>
52
53
 
53
54
  <template>
54
- <UModal
55
+ <AppModal
56
+ size="form"
55
57
  v-model:open="open"
56
58
  :title="t('tutorial.catalogue.title')"
57
59
  :description="t('tutorial.catalogue.intro')"
58
- :ui="{ content: 'max-w-2xl' }"
59
60
  >
60
61
  <template #body>
61
62
  <div class="space-y-4" data-testid="tutorial-catalogue">
@@ -162,5 +163,5 @@ const statusColor = (row: TutorialCatalogueRow) =>
162
163
  </UButton>
163
164
  </div>
164
165
  </template>
165
- </UModal>
166
+ </AppModal>
166
167
  </template>
@@ -15,6 +15,7 @@
15
15
  // prompt never auto-opens again), while closing without answering defers to next launch.
16
16
  import { TUTORIAL_ACTION_KEYS } from '~/utils/tutorial'
17
17
  import EmptyState from '~/components/common/EmptyState.vue'
18
+ import AppModal from '~/components/common/AppModal.vue'
18
19
 
19
20
  const { t } = useI18n()
20
21
  const tutorial = useTutorialStore()
@@ -34,7 +35,7 @@ const undecided = computed(() => tutorial.decision === null)
34
35
  </script>
35
36
 
36
37
  <template>
37
- <UModal v-model:open="open" :title="t('tutorial.prompt.title')" :ui="{ content: 'max-w-lg' }">
38
+ <AppModal size="prompt" v-model:open="open" :title="t('tutorial.prompt.title')">
38
39
  <template #body>
39
40
  <!-- NOTE for spec authors: this marks the modal's BODY, so it is the "prompt is
40
41
  showing" signal, not a container for everything in the prompt. The footer's
@@ -125,5 +126,5 @@ const undecided = computed(() => tutorial.decision === null)
125
126
  </div>
126
127
  </div>
127
128
  </template>
128
- </UModal>
129
+ </AppModal>
129
130
  </template>
@@ -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 { repos, availableRepos, pulls, issues, branches, syncing, load } = ctx
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
- repos.value = []
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) upsertPull({ ...existing, state: 'closed', merged: true })
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) {