canopycms 0.0.67 → 0.0.68-int.101

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 (281) hide show
  1. package/README.md +24 -17
  2. package/dist/ai/handler.js +8 -0
  3. package/dist/api/admin-branch-health.js +17 -20
  4. package/dist/api/admin.d.ts +30 -9
  5. package/dist/api/admin.js +32 -2
  6. package/dist/api/assets.js +84 -76
  7. package/dist/api/branch-create-window.d.ts +12 -0
  8. package/dist/api/branch-create-window.js +13 -0
  9. package/dist/api/branch-review.d.ts +3 -3
  10. package/dist/api/branch-review.js +14 -5
  11. package/dist/api/branch-status.d.ts +5 -5
  12. package/dist/api/branch-status.js +26 -6
  13. package/dist/api/branch-withdraw.d.ts +2 -2
  14. package/dist/api/branch-withdraw.js +7 -2
  15. package/dist/api/branch.d.ts +15 -2
  16. package/dist/api/branch.js +118 -92
  17. package/dist/api/client.d.ts +18 -6
  18. package/dist/api/client.js +79 -12
  19. package/dist/api/content.d.ts +6 -5
  20. package/dist/api/content.js +44 -41
  21. package/dist/api/entries.js +16 -15
  22. package/dist/api/github-sync.d.ts +19 -1
  23. package/dist/api/github-sync.js +57 -10
  24. package/dist/api/index.d.ts +1 -1
  25. package/dist/api/reference-options.js +1 -1
  26. package/dist/api/resolve-references.js +14 -40
  27. package/dist/api/settings-helpers.d.ts +1 -1
  28. package/dist/api/settings-helpers.js +1 -4
  29. package/dist/api/user.d.ts +3 -0
  30. package/dist/api/user.js +3 -0
  31. package/dist/assets/asset-url.d.ts +22 -16
  32. package/dist/assets/asset-url.js +42 -25
  33. package/dist/assets/factory.d.ts +5 -0
  34. package/dist/assets/factory.js +5 -0
  35. package/dist/assets/finalize.js +3 -0
  36. package/dist/assets/index.d.ts +1 -1
  37. package/dist/assets/materialize.d.ts +153 -0
  38. package/dist/assets/materialize.js +402 -0
  39. package/dist/assets/store-local.d.ts +19 -5
  40. package/dist/assets/store-local.js +69 -10
  41. package/dist/assets/store-s3.d.ts +38 -7
  42. package/dist/assets/store-s3.js +128 -22
  43. package/dist/assets/transform-directives.d.ts +52 -13
  44. package/dist/assets/transform-directives.js +87 -27
  45. package/dist/assets/transform.d.ts +3 -3
  46. package/dist/assets/transform.js +13 -8
  47. package/dist/assets/types.d.ts +31 -4
  48. package/dist/auth/file-based-auth-cache.js +7 -8
  49. package/dist/authorization/content.d.ts +5 -4
  50. package/dist/authorization/content.js +5 -5
  51. package/dist/authorization/path.d.ts +11 -6
  52. package/dist/authorization/path.js +11 -6
  53. package/dist/authorization/types.d.ts +2 -2
  54. package/dist/branch-health.d.ts +7 -1
  55. package/dist/branch-health.js +8 -3
  56. package/dist/branch-metadata.d.ts +5 -5
  57. package/dist/branch-metadata.js +44 -36
  58. package/dist/branch-provisioning.d.ts +167 -0
  59. package/dist/branch-provisioning.js +501 -0
  60. package/dist/branch-schema-cache.d.ts +5 -3
  61. package/dist/branch-schema-cache.js +29 -11
  62. package/dist/branch-sparse.d.ts +23 -0
  63. package/dist/branch-sparse.js +118 -0
  64. package/dist/branch-workspace.d.ts +42 -3
  65. package/dist/branch-workspace.js +240 -94
  66. package/dist/build/asset-refs.d.ts +93 -0
  67. package/dist/build/asset-refs.js +251 -0
  68. package/dist/build-identity.d.ts +3 -0
  69. package/dist/build-identity.js +13 -0
  70. package/dist/cli/asset-refs.d.ts +52 -0
  71. package/dist/cli/asset-refs.js +194 -0
  72. package/dist/cli/cli.d.ts +15 -1
  73. package/dist/cli/cli.js +5928 -2195
  74. package/dist/cli/configured-asset-store.d.ts +12 -0
  75. package/dist/cli/configured-asset-store.js +46 -0
  76. package/dist/cli/generate-ai-content.js +2185 -587
  77. package/dist/cli/github-app-manifest.d.ts +4 -3
  78. package/dist/cli/github-app-manifest.js +4 -3
  79. package/dist/cli/init.js +30 -27
  80. package/dist/cli/migrate.js +12 -8
  81. package/dist/cli/sync.js +83 -37
  82. package/dist/cli/template-files/Dockerfile.cms.template +8 -0
  83. package/dist/cli/template-files/cdk-app.ts.template +4 -0
  84. package/dist/cli/template-files/cms-stack.ts.template +23 -12
  85. package/dist/cli/template-files/deploy-cms.yml.template +2 -0
  86. package/dist/client.d.ts +1 -1
  87. package/dist/client.js +1 -1
  88. package/dist/config/helpers.js +1 -2
  89. package/dist/config/schemas/config.d.ts +19 -32
  90. package/dist/config/schemas/config.js +9 -11
  91. package/dist/config/schemas/field.js +1 -0
  92. package/dist/config/schemas/media.d.ts +4 -13
  93. package/dist/config/schemas/media.js +5 -8
  94. package/dist/config/schemas/url.d.ts +4 -20
  95. package/dist/config/schemas/url.js +6 -22
  96. package/dist/config/types.d.ts +30 -20
  97. package/dist/config/validation.d.ts +6 -0
  98. package/dist/config/validation.js +37 -0
  99. package/dist/content-listing.d.ts +12 -12
  100. package/dist/content-listing.js +10 -6
  101. package/dist/content-reader.d.ts +11 -2
  102. package/dist/content-reader.js +22 -14
  103. package/dist/content-store.d.ts +37 -9
  104. package/dist/content-store.js +70 -43
  105. package/dist/content-tree.d.ts +1 -1
  106. package/dist/content-tree.js +2 -2
  107. package/dist/context.d.ts +27 -9
  108. package/dist/context.js +154 -70
  109. package/dist/editor/BranchManager.d.ts +7 -1
  110. package/dist/editor/BranchManager.js +29 -14
  111. package/dist/editor/CanopyEditor.d.ts +1 -1
  112. package/dist/editor/CanopyEditor.js +2 -6
  113. package/dist/editor/Editor.d.ts +4 -3
  114. package/dist/editor/Editor.js +32 -22
  115. package/dist/editor/FormRenderer.js +34 -18
  116. package/dist/editor/PreviewFrame.d.ts +24 -0
  117. package/dist/editor/PreviewFrame.js +140 -0
  118. package/dist/editor/admin/SystemHealthPanel.js +47 -7
  119. package/dist/editor/components/BranchesDrawer.d.ts +9 -0
  120. package/dist/editor/components/BranchesDrawer.js +4 -0
  121. package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
  122. package/dist/editor/components/NoEditPermissionNotice.js +17 -0
  123. package/dist/editor/context/AssetContext.d.ts +18 -35
  124. package/dist/editor/context/AssetContext.js +20 -29
  125. package/dist/editor/context/index.d.ts +1 -1
  126. package/dist/editor/context/index.js +1 -1
  127. package/dist/editor/editor-config.d.ts +1 -2
  128. package/dist/editor/editor-config.js +0 -23
  129. package/dist/editor/editor-utils.d.ts +24 -21
  130. package/dist/editor/editor-utils.js +75 -62
  131. package/dist/editor/fields/BlockField.d.ts +1 -0
  132. package/dist/editor/fields/BlockField.js +6 -4
  133. package/dist/editor/fields/CodeField.d.ts +1 -0
  134. package/dist/editor/fields/CodeField.js +2 -2
  135. package/dist/editor/fields/DateTimeField.d.ts +1 -0
  136. package/dist/editor/fields/DateTimeField.js +2 -2
  137. package/dist/editor/fields/FieldDescription.d.ts +16 -0
  138. package/dist/editor/fields/FieldDescription.js +11 -0
  139. package/dist/editor/fields/ImageField.d.ts +1 -0
  140. package/dist/editor/fields/ImageField.js +3 -2
  141. package/dist/editor/fields/InlineGroupField.js +4 -1
  142. package/dist/editor/fields/MarkdownField.d.ts +1 -0
  143. package/dist/editor/fields/MarkdownField.js +115 -20
  144. package/dist/editor/fields/NumberField.d.ts +1 -0
  145. package/dist/editor/fields/NumberField.js +2 -2
  146. package/dist/editor/fields/NumberListField.d.ts +1 -0
  147. package/dist/editor/fields/NumberListField.js +2 -2
  148. package/dist/editor/fields/ObjectField.d.ts +1 -0
  149. package/dist/editor/fields/ObjectField.js +5 -2
  150. package/dist/editor/fields/ReferenceField.d.ts +1 -0
  151. package/dist/editor/fields/ReferenceField.js +5 -4
  152. package/dist/editor/fields/SelectField.d.ts +1 -0
  153. package/dist/editor/fields/SelectField.js +2 -2
  154. package/dist/editor/fields/StringListField.d.ts +1 -0
  155. package/dist/editor/fields/StringListField.js +2 -2
  156. package/dist/editor/fields/TextField.d.ts +1 -0
  157. package/dist/editor/fields/TextField.js +2 -2
  158. package/dist/editor/fields/ToggleField.d.ts +1 -0
  159. package/dist/editor/fields/ToggleField.js +7 -4
  160. package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
  161. package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
  162. package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
  163. package/dist/editor/fields/mdx-jsx-support.js +137 -0
  164. package/dist/editor/hooks/create-branch-request.d.ts +20 -0
  165. package/dist/editor/hooks/create-branch-request.js +52 -0
  166. package/dist/editor/hooks/useBranchActions.d.ts +13 -2
  167. package/dist/editor/hooks/useBranchActions.js +48 -17
  168. package/dist/editor/hooks/useBranchManager.d.ts +21 -0
  169. package/dist/editor/hooks/useBranchManager.js +204 -37
  170. package/dist/editor/hooks/useBranchesData.d.ts +6 -0
  171. package/dist/editor/hooks/useBranchesData.js +9 -3
  172. package/dist/editor/hooks/useCommentSystem.js +25 -6
  173. package/dist/editor/hooks/useDraftManager.d.ts +16 -1
  174. package/dist/editor/hooks/useDraftManager.js +193 -36
  175. package/dist/editor/hooks/useEntriesData.js +3 -3
  176. package/dist/editor/hooks/useEntryManager.d.ts +2 -1
  177. package/dist/editor/hooks/useEntryManager.js +34 -19
  178. package/dist/editor/media/AssetCard.d.ts +1 -1
  179. package/dist/editor/media/crop-math.d.ts +3 -4
  180. package/dist/editor/media/crop-math.js +11 -22
  181. package/dist/editor/media/editor-image-src.d.ts +7 -0
  182. package/dist/editor/media/editor-image-src.js +12 -0
  183. package/dist/editor/preview-asset-base.d.ts +8 -0
  184. package/dist/editor/preview-asset-base.js +13 -0
  185. package/dist/editor/preview-bridge.d.ts +9 -21
  186. package/dist/editor/preview-bridge.js +19 -139
  187. package/dist/editor/preview-path.d.ts +10 -0
  188. package/dist/editor/preview-path.js +20 -0
  189. package/dist/editor/theme.js +2 -2
  190. package/dist/entry-schema-registry.d.ts +4 -4
  191. package/dist/entry-schema-registry.js +10 -6
  192. package/dist/entry-schema.d.ts +27 -4
  193. package/dist/entry-schema.js +20 -3
  194. package/dist/git-manager.d.ts +173 -6
  195. package/dist/git-manager.js +594 -76
  196. package/dist/github-service.d.ts +15 -0
  197. package/dist/github-service.js +19 -1
  198. package/dist/http/handler.js +37 -13
  199. package/dist/http/index.d.ts +2 -0
  200. package/dist/http/index.js +2 -0
  201. package/dist/http/router.d.ts +2 -0
  202. package/dist/http/router.js +2 -1
  203. package/dist/http/types.d.ts +2 -0
  204. package/dist/http/worker-not-ready.d.ts +10 -0
  205. package/dist/http/worker-not-ready.js +24 -0
  206. package/dist/operating-mode/client-unsafe-strategy.js +0 -6
  207. package/dist/operating-mode/types.d.ts +1 -4
  208. package/dist/paths/branch-name.d.ts +5 -0
  209. package/dist/paths/branch-name.js +9 -0
  210. package/dist/paths/branch.d.ts +6 -2
  211. package/dist/paths/branch.js +16 -10
  212. package/dist/paths/index.d.ts +3 -3
  213. package/dist/paths/index.js +3 -3
  214. package/dist/paths/normalize.d.ts +7 -0
  215. package/dist/paths/normalize.js +9 -0
  216. package/dist/paths/validation.js +0 -2
  217. package/dist/preview.d.ts +8 -0
  218. package/dist/preview.js +7 -0
  219. package/dist/reference-resolver.d.ts +5 -21
  220. package/dist/reference-resolver.js +9 -44
  221. package/dist/resolve-canopy-user.js +2 -1
  222. package/dist/schema/schema-store.d.ts +54 -31
  223. package/dist/schema/schema-store.js +77 -31
  224. package/dist/server.d.ts +64 -9
  225. package/dist/server.js +48 -8
  226. package/dist/services.d.ts +18 -6
  227. package/dist/services.js +85 -70
  228. package/dist/settings-workspace.d.ts +23 -3
  229. package/dist/settings-workspace.js +197 -45
  230. package/dist/static/seo.d.ts +2 -14
  231. package/dist/static/seo.js +2 -25
  232. package/dist/submission-attribution.d.ts +73 -0
  233. package/dist/submission-attribution.js +221 -0
  234. package/dist/sync-core.d.ts +12 -1
  235. package/dist/sync-core.js +31 -13
  236. package/dist/task-queue/worker-status.d.ts +8 -0
  237. package/dist/task-queue/worker-status.js +17 -0
  238. package/dist/types.d.ts +31 -2
  239. package/dist/utils/content-serialize.d.ts +5 -2
  240. package/dist/utils/content-serialize.js +98 -24
  241. package/dist/utils/content-write-lock.d.ts +18 -7
  242. package/dist/utils/content-write-lock.js +18 -9
  243. package/dist/utils/debug.d.ts +8 -0
  244. package/dist/utils/debug.js +10 -2
  245. package/dist/utils/git.d.ts +32 -0
  246. package/dist/utils/git.js +42 -0
  247. package/dist/utils/occ-json-write.js +10 -2
  248. package/dist/utils/provision-log.d.ts +25 -0
  249. package/dist/utils/provision-log.js +49 -0
  250. package/dist/utils/provisioning-lock.d.ts +35 -5
  251. package/dist/utils/provisioning-lock.js +73 -12
  252. package/dist/utils/request-timing.d.ts +25 -0
  253. package/dist/utils/request-timing.js +101 -0
  254. package/dist/utils/sanitize-href.d.ts +8 -22
  255. package/dist/utils/sanitize-href.js +11 -28
  256. package/dist/utils/url-prefix.d.ts +30 -0
  257. package/dist/utils/url-prefix.js +61 -0
  258. package/dist/utils/yaml-source-splice.d.ts +58 -0
  259. package/dist/utils/yaml-source-splice.js +515 -0
  260. package/dist/validation/entry-validator.js +9 -3
  261. package/dist/version.d.ts +1 -0
  262. package/dist/version.js +3 -0
  263. package/dist/worker/canopy-state.d.ts +45 -0
  264. package/dist/worker/canopy-state.js +75 -0
  265. package/dist/worker/cms-worker.d.ts +8 -3
  266. package/dist/worker/cms-worker.js +38 -2
  267. package/dist/worker/git-sync.d.ts +30 -12
  268. package/dist/worker/git-sync.js +253 -58
  269. package/dist/worker/history-rewrite.d.ts +1 -1
  270. package/dist/worker/history-rewrite.js +1 -1
  271. package/dist/worker/provisioned-workspace.d.ts +35 -0
  272. package/dist/worker/provisioned-workspace.js +50 -0
  273. package/dist/worker/rebase.d.ts +1 -1
  274. package/dist/worker/rebase.js +95 -23
  275. package/dist/worker/remote-git-maintenance.d.ts +3 -0
  276. package/dist/worker/remote-git-maintenance.js +11 -0
  277. package/dist/worker/sparse-cone.d.ts +20 -0
  278. package/dist/worker/sparse-cone.js +132 -0
  279. package/dist/worker/task-runner.js +99 -14
  280. package/dist/worker/worker-context.d.ts +6 -1
  281. package/package.json +8 -2
@@ -0,0 +1,20 @@
1
+ import type { BranchListItem, CreateBranchBody } from '../../api/branch.js';
2
+ import { type CanopyApiClient } from '../../api/client.js';
3
+ /** @internal Exported for tests. Past the 60 s a Lambda behind CloudFront gets, so its 504 lands first. */
4
+ export declare const CREATE_BRANCH_DEADLINE_MS = 90000;
5
+ /** @internal Exported for tests. */
6
+ export declare const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
7
+ export type CreateBranchOutcome = {
8
+ kind: 'created';
9
+ branch: BranchListItem | undefined;
10
+ } | {
11
+ kind: 'failed';
12
+ message: string;
13
+ };
14
+ /**
15
+ * Create a branch. Past the deadline, after an error page from in front of the API, or after a
16
+ * busy 503 the branch may exist anyway, so the branch list decides; retrying is safe because a
17
+ * create publishes the whole branch or nothing. `userId` is the current user, the only creator
18
+ * whose listed branch can be this create's result.
19
+ */
20
+ export declare function requestBranchCreate(apiClient: Pick<CanopyApiClient, 'branches'>, body: CreateBranchBody, userId: string | undefined, deadlineMs?: number): Promise<CreateBranchOutcome>;
@@ -0,0 +1,52 @@
1
+ import { isCreatorsRecentBranch } from '../../api/branch-create-window.js';
2
+ import { isNonApiResponse } from '../../api/client.js';
3
+ import { sanitizeBranchName } from '../../paths/branch-name.js';
4
+ /** @internal Exported for tests. Past the 60 s a Lambda behind CloudFront gets, so its 504 lands first. */
5
+ export const CREATE_BRANCH_DEADLINE_MS = 90_000;
6
+ /** @internal Exported for tests. */
7
+ export const CREATE_TIMED_OUT_MESSAGE = "Creating the branch didn't finish (the server timed out). It's safe to try again.";
8
+ const DEADLINE = Symbol('deadline');
9
+ /**
10
+ * Create a branch. Past the deadline, after an error page from in front of the API, or after a
11
+ * busy 503 the branch may exist anyway, so the branch list decides; retrying is safe because a
12
+ * create publishes the whole branch or nothing. `userId` is the current user, the only creator
13
+ * whose listed branch can be this create's result.
14
+ */
15
+ export async function requestBranchCreate(apiClient, body, userId, deadlineMs = CREATE_BRANCH_DEADLINE_MS) {
16
+ let timer;
17
+ const deadline = new Promise((resolve) => {
18
+ timer = setTimeout(() => resolve(DEADLINE), deadlineMs);
19
+ });
20
+ const result = await Promise.race([apiClient.branches.create(body), deadline]).finally(() => clearTimeout(timer));
21
+ if (result === DEADLINE)
22
+ return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
23
+ if (result.ok)
24
+ return { kind: 'created', branch: result.data?.branch };
25
+ if (isNonApiResponse(result))
26
+ return settle(apiClient, body.branch, userId, CREATE_TIMED_OUT_MESSAGE);
27
+ const message = result.error || 'Failed to create branch';
28
+ return result.status === 503
29
+ ? settle(apiClient, body.branch, userId, message)
30
+ : { kind: 'failed', message };
31
+ }
32
+ /** A same-named branch the server would not answer this create with is a name conflict. */
33
+ async function settle(apiClient, requested, userId, message) {
34
+ const listed = await findListedBranch(apiClient, requested);
35
+ if (!listed)
36
+ return { kind: 'failed', message };
37
+ if (isCreatorsRecentBranch(listed, userId))
38
+ return { kind: 'created', branch: listed };
39
+ return { kind: 'failed', message: `A branch named "${listed.name}" already exists` };
40
+ }
41
+ async function findListedBranch(apiClient, requested) {
42
+ const name = sanitizeBranchName(requested);
43
+ try {
44
+ const listing = await apiClient.branches.list();
45
+ if (!listing.ok)
46
+ return undefined;
47
+ return listing.data?.branches.find((branch) => sanitizeBranchName(branch.name) === name);
48
+ }
49
+ catch {
50
+ return undefined;
51
+ }
52
+ }
@@ -1,17 +1,28 @@
1
+ import type { BranchListItem } from '../../api/branch.js';
2
+ import type { UnsavedSummary } from './useDraftManager.js';
1
3
  export interface UseBranchActionsOptions {
2
4
  branchName: string;
3
5
  setBranchName: (name: string) => void;
4
- isAnyDirty: () => boolean;
6
+ /** From useDraftManager's `resolveUnsaved`: what a branch-level action would leave behind. */
7
+ getUnsaved: () => Promise<UnsavedSummary>;
5
8
  onReloadBranches: () => Promise<void>;
9
+ /** Receives the branch the server just created, so it can be shown before any listing includes it. */
10
+ onBranchCreated: (branch: BranchListItem) => void;
6
11
  onBranchSwitch?: (branch: string) => void;
12
+ /** The current user; a create the server never answered adopts only a listed branch they made. */
13
+ userId?: string;
7
14
  }
8
15
  export interface UseBranchActionsReturn {
9
16
  handleBranchChange: (branch: string | null) => Promise<void>;
17
+ /** True when nothing is unsaved or the user accepted the warning. Run before any in-flight UI. */
18
+ confirmCreate: () => Promise<boolean>;
10
19
  handleCreateBranch: (branch: {
11
20
  name: string;
12
21
  title?: string;
13
22
  description?: string;
14
- }) => Promise<void>;
23
+ }) => Promise<boolean>;
24
+ /** True while the unsaved-changes confirm is on screen. */
25
+ confirmOpen: boolean;
15
26
  }
16
27
  /**
17
28
  * Custom hook for branch navigation actions with dirty check support.
@@ -1,13 +1,25 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
2
3
  import { modals } from '@mantine/modals';
3
4
  import { notifications } from '@mantine/notifications';
4
5
  import { Text } from '@mantine/core';
5
6
  import { useApiClient } from '../context/index.js';
7
+ import { requestBranchCreate } from './create-branch-request.js';
8
+ const MAX_LISTED_LABELS = 5;
9
+ /** "Unsaved changes in: A, B." */
10
+ const describeUnsaved = ({ labels }) => {
11
+ if (labels.length === 0)
12
+ return 'You have unsaved changes.';
13
+ const shown = labels.slice(0, MAX_LISTED_LABELS).join(', ');
14
+ const rest = labels.length - MAX_LISTED_LABELS;
15
+ return `Unsaved changes in: ${shown}${rest > 0 ? ` and ${rest} more` : ''}.`;
16
+ };
6
17
  /**
7
18
  * Custom hook for branch navigation actions with dirty check support.
8
19
  */
9
20
  export function useBranchActions(options) {
10
21
  const apiClient = useApiClient();
22
+ const [confirmOpen, setConfirmOpen] = useState(false);
11
23
  const performBranchSwitch = (next) => {
12
24
  options.setBranchName(next);
13
25
  if (typeof window !== 'undefined') {
@@ -17,62 +29,81 @@ export function useBranchActions(options) {
17
29
  }
18
30
  options.onBranchSwitch?.(next);
19
31
  };
20
- const confirmIfDirty = async (message) => {
21
- if (!options.isAnyDirty())
32
+ const confirmIfDirty = async (question) => {
33
+ const unsaved = await options.getUnsaved();
34
+ if (unsaved.count === 0)
22
35
  return true;
23
36
  return new Promise((resolve) => {
37
+ const settle = (value) => {
38
+ // Mantine calls onClose and onCancel from its modals reducer, which runs while
39
+ // ModalsProvider renders, so a setState here must be deferred; resolving needs no deferral.
40
+ queueMicrotask(() => setConfirmOpen(false));
41
+ resolve(value);
42
+ };
43
+ setConfirmOpen(true);
24
44
  modals.openConfirmModal({
25
45
  title: 'Unsaved Changes',
26
- children: _jsx(Text, { size: "sm", children: message }),
46
+ // Drafts are stored per branch, so leaving does not lose them.
47
+ children: (_jsxs(Text, { size: "sm", children: [describeUnsaved(unsaved), " Your drafts stay on \u201C", options.branchName, "\u201D and come back when you return. ", question] })),
27
48
  labels: { confirm: 'Continue Anyway', cancel: 'Cancel' },
28
49
  confirmProps: { color: 'red' },
29
- onCancel: () => resolve(false),
30
- onConfirm: () => resolve(true),
50
+ onCancel: () => settle(false),
51
+ onConfirm: () => settle(true),
52
+ // Escape and overlay dismissals fire only onClose. Mantine also calls it
53
+ // right after onConfirm, which is harmless: a promise settles once.
54
+ onClose: () => settle(false),
31
55
  });
32
56
  });
33
57
  };
34
58
  const handleBranchChange = async (next) => {
35
59
  if (!next || next === options.branchName)
36
60
  return;
37
- const confirmed = await confirmIfDirty('You have unsaved changes. Switch branches anyway?');
61
+ const confirmed = await confirmIfDirty('Switch branches anyway?');
38
62
  if (!confirmed)
39
63
  throw new Error('User cancelled branch switch');
40
64
  performBranchSwitch(next);
41
65
  };
66
+ const confirmCreate = () => confirmIfDirty('Create the new branch anyway?');
67
+ /** Resolves true when the branch was created and switched to, false when it was not. */
42
68
  const handleCreateBranch = async (branch) => {
43
- const confirmed = await confirmIfDirty('Create new branch without saving changes?');
44
- if (!confirmed)
45
- return;
46
69
  try {
47
- const result = await apiClient.branches.create({
70
+ const outcome = await requestBranchCreate(apiClient, {
48
71
  branch: branch.name,
49
72
  title: branch.title,
50
73
  description: branch.description,
51
- });
52
- if (!result.ok) {
53
- throw new Error(result.error || 'Failed to create branch');
74
+ }, options.userId);
75
+ if (outcome.kind === 'failed') {
76
+ throw new Error(outcome.message);
54
77
  }
78
+ const created = outcome.branch;
79
+ if (created)
80
+ options.onBranchCreated(created);
55
81
  // The server sanitizes the branch name (e.g. "feature/x" -> "feature-x")
56
82
  // before persisting it. Adopt the canonical name from the response so
57
83
  // the client doesn't end up stuck on a name the server never saved.
58
- const createdName = result.data?.branch?.name ?? branch.name;
84
+ const createdName = created?.name ?? branch.name;
59
85
  notifications.show({
60
86
  message: createdName === branch.name
61
87
  ? `Branch "${createdName}" created`
62
88
  : `Branch "${createdName}" created (renamed from "${branch.name}")`,
63
89
  color: 'green',
64
90
  });
65
- await options.onReloadBranches();
66
- // Switch to new branch (already confirmed dirty check)
67
91
  performBranchSwitch(createdName);
92
+ // Not awaited: a listing can lag the create, and the switch must not wait
93
+ // on it. loadBranches reports its own failures and never rejects.
94
+ void options.onReloadBranches();
95
+ return true;
68
96
  }
69
97
  catch (err) {
70
98
  const message = err instanceof Error ? err.message : 'Failed to create branch';
71
99
  notifications.show({ message, color: 'red' });
100
+ return false;
72
101
  }
73
102
  };
74
103
  return {
75
104
  handleBranchChange,
105
+ confirmCreate,
76
106
  handleCreateBranch,
107
+ confirmOpen,
77
108
  };
78
109
  }
@@ -2,6 +2,25 @@ import type { ConflictStatus, PullRequestState, SyncStatus } from '../../types.j
2
2
  import type { OperatingMode } from '../../operating-mode/index.js';
3
3
  import type { CommentThread } from '../../comment-store.js';
4
4
  import type { BranchListItem } from '../../api/branch.js';
5
+ /** What the withdraw and delete dialogs say about the branch's pull request. */
6
+ type BranchPullRequest = Pick<BranchListItem, 'pullRequestNumber' | 'pullRequestState'>;
7
+ /**
8
+ * The withdraw dialog's pull-request bullet, matching what api/branch-withdraw.ts does: an open
9
+ * PR becomes a draft; a closed one is left alone and its number dropped from the branch, so a
10
+ * resubmit opens a new one. A merged PR archives its branch, which withdraw refuses, so that
11
+ * bullet is only seen on a listing that lags.
12
+ * @internal Exported for tests.
13
+ */
14
+ export declare function withdrawPullRequestBullet(pr: BranchPullRequest): string | undefined;
15
+ /**
16
+ * How long a branch this session created stays overlaid on listings. Each warm
17
+ * server container caches the shared filesystem separately, so for its
18
+ * attribute/dentry cache window (~60s, see docs/concurrency.md window A) a
19
+ * listing can lack the branch even after another container's listing showed
20
+ * it. Twice that absorbs request latency; past it, listings alone decide.
21
+ * @internal Exported only for the test that pins it.
22
+ */
23
+ export declare const CREATED_BRANCH_GRACE_MS = 120000;
5
24
  interface BranchSummary {
6
25
  name: string;
7
26
  status: string;
@@ -58,6 +77,8 @@ export interface UseBranchManagerReturn {
58
77
  branches: BranchListItem[];
59
78
  branchSummaries: BranchSummary[];
60
79
  currentBranch: BranchListItem | undefined;
80
+ /** Shows a branch the server just created before any listing includes it. */
81
+ addCreatedBranch: (branch: BranchListItem) => void;
61
82
  handleSubmit: (branchName: string) => Promise<void>;
62
83
  handleWithdraw: (branchName: string) => Promise<void>;
63
84
  handleRequestChanges: (branchName: string) => Promise<void>;
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useMemo, useState } from 'react';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useSWRConfig } from 'swr';
5
5
  import { Text } from '@mantine/core';
6
6
  import { modals } from '@mantine/modals';
@@ -58,30 +58,57 @@ const showSubmitConfirmation = (branchName, onConfirm, onDismiss) => {
58
58
  ...confirmModalHandlers(onConfirm, onDismiss),
59
59
  });
60
60
  };
61
- const showWithdrawConfirmation = (branchName, onConfirm, onDismiss) => {
61
+ /**
62
+ * The withdraw dialog's pull-request bullet, matching what api/branch-withdraw.ts does: an open
63
+ * PR becomes a draft; a closed one is left alone and its number dropped from the branch, so a
64
+ * resubmit opens a new one. A merged PR archives its branch, which withdraw refuses, so that
65
+ * bullet is only seen on a listing that lags.
66
+ * @internal Exported for tests.
67
+ */
68
+ export function withdrawPullRequestBullet(pr) {
69
+ if (!pr.pullRequestNumber)
70
+ return undefined;
71
+ const label = `pull request #${pr.pullRequestNumber}`;
72
+ if (pr.pullRequestState === 'closed') {
73
+ return `Leave the closed ${label} as it is; submitting again opens a new one`;
74
+ }
75
+ if (pr.pullRequestState === 'merged')
76
+ return `Leave the merged ${label} as it is`;
77
+ return `Convert ${label} to a draft`;
78
+ }
79
+ const showWithdrawConfirmation = (branchName, pr, onConfirm, onDismiss) => {
80
+ const bullets = [
81
+ withdrawPullRequestBullet(pr),
82
+ 'Change the branch status back to "editing"',
83
+ 'Remove from review queue',
84
+ ].filter((b) => b !== undefined);
62
85
  modals.openConfirmModal({
63
86
  title: 'Withdraw Branch from Review',
64
- children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Are you sure you want to withdraw "${branchName}" from review?\n\nThis will:\n• Convert the pull request to a draft\n• Change the branch status back to "editing"\n• Remove from review queue` })),
87
+ children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Are you sure you want to withdraw "${branchName}" from review?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}` })),
65
88
  labels: { confirm: 'Withdraw Branch', cancel: 'Cancel' },
66
89
  confirmProps: { color: 'orange' },
67
90
  ...confirmModalHandlers(onConfirm, onDismiss),
68
91
  });
69
92
  };
70
93
  /**
71
- * Unlike submit/withdraw (both reversible), delete is irreversible -- it
72
- * unlinks branch.json, removes the clone, and removes the branch head from
73
- * the local git mirror. Before this, `handleDelete` below called
74
- * `apiClient.branches.delete` immediately on click with no confirmation
75
- * anywhere in the chain (BranchManager.tsx's delete button calls `onDelete`
76
- * directly), while submit and withdraw -- both reversible -- each went
77
- * through this same `modals.openConfirmModal` pattern. This closes that gap;
78
- * `color: 'red'` marks it as the destructive one, matching the button's own
79
- * color in BranchManager.tsx.
94
+ * Delete is the one irreversible branch action: it unlinks branch.json and removes the clone,
95
+ * the local mirror's head and, for a branch with a PR, the GitHub branch. BranchManager.tsx's
96
+ * delete button calls `onDelete` directly, so this dialog is the only confirmation; `color:
97
+ * 'red'` matches that button.
80
98
  */
81
- const showDeleteConfirmation = (branchName, onConfirm, onDismiss) => {
99
+ const showDeleteConfirmation = (branchName, pr, onConfirm, onDismiss) => {
100
+ // The server deletes the GitHub branch only for a branch with a PR (api/github-sync.ts's
101
+ // syncDeleteRemoteBranch); GitHub closes a PR whose branch is deleted.
102
+ const bullets = [
103
+ 'Permanently remove the branch and its clone',
104
+ pr.pullRequestNumber
105
+ ? `Delete its branch on GitHub too, which closes pull request #${pr.pullRequestNumber} if it is still open`
106
+ : undefined,
107
+ 'Discard any unsaved or unmerged changes',
108
+ ].filter((b) => b !== undefined);
82
109
  modals.openConfirmModal({
83
110
  title: 'Delete Branch',
84
- children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Are you sure you want to delete "${branchName}"?\n\nThis will:\n• Permanently remove the branch and its clone\n• Discard any unsaved or unmerged changes\n\nThis cannot be undone.` })),
111
+ children: (_jsx(Text, { size: "sm", style: { whiteSpace: 'pre-line' }, children: `Are you sure you want to delete "${branchName}"?\n\nThis will:\n${bullets.map((b) => `• ${b}`).join('\n')}\n\nThis cannot be undone.` })),
85
112
  labels: { confirm: 'Delete Branch', cancel: 'Cancel' },
86
113
  // data-testid is load-bearing, not decoration: the e2e helper
87
114
  // (apps/test-app/e2e/fixtures/branch-page.ts's deleteBranch) already
@@ -92,6 +119,46 @@ const showDeleteConfirmation = (branchName, onConfirm, onDismiss) => {
92
119
  ...confirmModalHandlers(onConfirm, onDismiss),
93
120
  });
94
121
  };
122
+ /**
123
+ * How long a branch this session created stays overlaid on listings. Each warm
124
+ * server container caches the shared filesystem separately, so for its
125
+ * attribute/dentry cache window (~60s, see docs/concurrency.md window A) a
126
+ * listing can lack the branch even after another container's listing showed
127
+ * it. Twice that absorbs request latency; past it, listings alone decide.
128
+ * @internal Exported only for the test that pins it.
129
+ */
130
+ export const CREATED_BRANCH_GRACE_MS = 120_000;
131
+ /** The server listing plus any pending branches it lacks; the server's copy always wins. */
132
+ function mergePendingBranches(listed, pending) {
133
+ const listedNames = new Set(listed.map((b) => b.name));
134
+ const missing = pending.filter((p) => !listedNames.has(p.branch.name));
135
+ return missing.length === 0 ? listed : [...listed, ...missing.map((p) => p.branch)];
136
+ }
137
+ /**
138
+ * Applies one received listing to the pending branches: drops those it arrived
139
+ * past the grace window for, and takes the listing's copy of the rest. Written
140
+ * so a listing with no arrival time (NaN) drops nothing.
141
+ */
142
+ function reconcilePendingBranches(pending, listed, receivedAt) {
143
+ const listedByName = new Map(listed.map((b) => [b.name, b]));
144
+ let changed = false;
145
+ const next = [];
146
+ for (const p of pending) {
147
+ if (receivedAt - p.addedAt > CREATED_BRANCH_GRACE_MS) {
148
+ changed = true;
149
+ continue;
150
+ }
151
+ const listedCopy = listedByName.get(p.branch.name);
152
+ if (listedCopy && listedCopy !== p.branch) {
153
+ changed = true;
154
+ next.push({ branch: listedCopy, addedAt: p.addedAt });
155
+ }
156
+ else {
157
+ next.push(p);
158
+ }
159
+ }
160
+ return changed ? next : pending;
161
+ }
95
162
  /**
96
163
  * Custom hook for managing git branches.
97
164
  */
@@ -100,7 +167,40 @@ export function useBranchManager(options) {
100
167
  const { mutate: globalMutate } = useSWRConfig();
101
168
  const [branchName, setBranchName] = useState(options.initialBranch);
102
169
  const { data: branchesData, error: branchesError, isValidating: branchesIsValidating, } = useBranchesData(apiClient);
103
- const branches = useMemo(() => branchesData?.branches ?? [], [branchesData]);
170
+ const [pendingBranches, setPendingBranches] = useState([]);
171
+ const branches = useMemo(() => mergePendingBranches(branchesData?.branches ?? [], pendingBranches), [branchesData, pendingBranches]);
172
+ const addCreatedBranch = useCallback((branch) => {
173
+ setPendingBranches((prev) => [
174
+ ...prev.filter((p) => p.branch.name !== branch.name),
175
+ { branch, addedAt: Date.now() },
176
+ ]);
177
+ }, []);
178
+ const forgetCreatedBranch = (name) => {
179
+ setPendingBranches((prev) => {
180
+ const kept = prev.filter((p) => p.branch.name !== name);
181
+ return kept.length === prev.length ? prev : kept;
182
+ });
183
+ };
184
+ // After a workflow action, a pending branch's overlaid copy is stale. The
185
+ // server's returned copy replaces it, so a listing that still lacks the
186
+ // branch shows its new status instead of hiding it; without one, forgetting
187
+ // it fails closed rather than showing the pre-action state.
188
+ const updateCreatedBranch = (name, branch) => {
189
+ if (!branch) {
190
+ forgetCreatedBranch(name);
191
+ return;
192
+ }
193
+ setPendingBranches((prev) => prev.some((p) => p.branch.name === name)
194
+ ? prev.map((p) => (p.branch.name === name ? { ...p, branch } : p))
195
+ : prev);
196
+ };
197
+ // Each received listing is a new `branchesData` (see `BranchesData.receivedAt`);
198
+ // the grace window is measured to its arrival.
199
+ useEffect(() => {
200
+ if (!branchesData)
201
+ return;
202
+ setPendingBranches((prev) => reconcilePendingBranches(prev, branchesData.branches, branchesData.receivedAt));
203
+ }, [branchesData]);
104
204
  // Adopt the server's default branch once data arrives, if nothing pinned one.
105
205
  useEffect(() => {
106
206
  if (!branchName && branchesData?.defaultBranch) {
@@ -190,11 +290,13 @@ export function useBranchManager(options) {
190
290
  // bound hook (and anything else reading BRANCHES_KEY) picks it up.
191
291
  // Side effects (default-branch adoption, error/success notifications) are
192
292
  // driven by the effects above, which react to that same cache write.
193
- const loadBranches = async () => {
293
+ // Resolves to the fresh listing, or undefined when the fetch failed.
294
+ const reloadBranches = async () => {
194
295
  options.setBusy(true);
195
296
  try {
196
297
  const fresh = await fetchBranches(apiClient);
197
298
  await globalMutate(BRANCHES_KEY, fresh, { revalidate: false });
299
+ return fresh;
198
300
  }
199
301
  catch (err) {
200
302
  console.error(err);
@@ -205,14 +307,61 @@ export function useBranchManager(options) {
205
307
  color: 'red',
206
308
  autoClose: false,
207
309
  });
310
+ return undefined;
208
311
  }
209
312
  finally {
210
313
  options.setBusy(false);
211
314
  }
212
315
  };
213
- const handleSubmit = async (branchNameToSubmit) => {
214
- return new Promise((resolve, reject) => {
316
+ const loadBranches = async () => {
317
+ await reloadBranches();
318
+ };
319
+ // The confirm dialogs' handlers run long after the render that opened them, so they read
320
+ // these at confirm time rather than from that render's closure.
321
+ const latest = useRef({ branchName, branches, branchesData });
322
+ latest.current = { branchName, branches, branchesData };
323
+ // A legacy deep link can carry the raw form of a listed name; the actions key, look up and
324
+ // send the listed name, resolved the way `currentBranch` resolves it.
325
+ const listedName = (name) => latest.current.branches.some((b) => b.name === name) ? name : sanitizeBranchName(name);
326
+ const pullRequestOf = (name) => {
327
+ const b = latest.current.branches.find((x) => x.name === name);
328
+ return { pullRequestNumber: b?.pullRequestNumber, pullRequestState: b?.pullRequestState };
329
+ };
330
+ // Keys of the actions whose confirm is open or whose work is in flight.
331
+ const actionsInFlight = useRef(new Set());
332
+ /**
333
+ * Runs `run` unless the same action on the same branch already has its confirm open or its
334
+ * work in flight; then this request opens nothing and resolves at once. Mantine closes only
335
+ * the confirm that was confirmed, so a second one opened by a repeated click would stay up
336
+ * after the action succeeded.
337
+ */
338
+ const singleFlight = async (action, branch, run) => {
339
+ const key = JSON.stringify([action, branch]);
340
+ if (actionsInFlight.current.has(key))
341
+ return;
342
+ actionsInFlight.current.add(key);
343
+ try {
344
+ await run();
345
+ }
346
+ finally {
347
+ actionsInFlight.current.delete(key);
348
+ }
349
+ };
350
+ const handleSubmit = (requested) => {
351
+ const branchNameToSubmit = listedName(requested);
352
+ return singleFlight('submit', branchNameToSubmit, () => new Promise((resolve, reject) => {
215
353
  showSubmitConfirmation(branchNameToSubmit, async () => {
354
+ // A listing that arrived while the dialog was open may show the branch already
355
+ // submitted; submitting it again would only fail.
356
+ const listed = latest.current.branches.find((b) => b.name === branchNameToSubmit);
357
+ if (listed?.status === 'submitted') {
358
+ notifications.show({
359
+ message: 'Branch is already submitted for review',
360
+ color: 'blue',
361
+ });
362
+ resolve();
363
+ return;
364
+ }
216
365
  options.setBusy(true);
217
366
  try {
218
367
  const result = await apiClient.workflow.submit({
@@ -225,6 +374,7 @@ export function useBranchManager(options) {
225
374
  message: 'Branch submitted for review',
226
375
  color: 'green',
227
376
  });
377
+ updateCreatedBranch(branchNameToSubmit, result.data?.branch);
228
378
  await loadBranches();
229
379
  resolve();
230
380
  }
@@ -237,17 +387,15 @@ export function useBranchManager(options) {
237
387
  options.setBusy(false);
238
388
  }
239
389
  },
240
- // Dismissal is not an error. Rejecting here meant clicking Cancel
241
- // logged a console error at the only call site (Editor.tsx catches
242
- // these into console.error), which is both noise and a trip hazard
243
- // for the CI=1 stray-console gate. Settled once, by onCancel OR
244
- // onClose, so an Escape/overlay dismissal can't leave this pending.
390
+ // Dismissal is not an error: the call sites log rejections to the console. Settled
391
+ // once, by onCancel OR onClose, so an Escape/overlay dismissal can't leave this pending.
245
392
  () => resolve());
246
- });
393
+ }));
247
394
  };
248
- const handleWithdraw = async (branchNameToWithdraw) => {
249
- return new Promise((resolve, reject) => {
250
- showWithdrawConfirmation(branchNameToWithdraw, async () => {
395
+ const handleWithdraw = (requested) => {
396
+ const branchNameToWithdraw = listedName(requested);
397
+ return singleFlight('withdraw', branchNameToWithdraw, () => new Promise((resolve, reject) => {
398
+ showWithdrawConfirmation(branchNameToWithdraw, pullRequestOf(branchNameToWithdraw), async () => {
251
399
  options.setBusy(true);
252
400
  try {
253
401
  const result = await apiClient.workflow.withdraw({
@@ -257,6 +405,7 @@ export function useBranchManager(options) {
257
405
  throw new Error(result.error || 'Failed to withdraw branch');
258
406
  }
259
407
  notifications.show({ message: 'Branch withdrawn', color: 'blue' });
408
+ updateCreatedBranch(branchNameToWithdraw, result.data?.branch);
260
409
  await loadBranches();
261
410
  resolve();
262
411
  }
@@ -269,7 +418,7 @@ export function useBranchManager(options) {
269
418
  options.setBusy(false);
270
419
  }
271
420
  }, () => resolve());
272
- });
421
+ }));
273
422
  };
274
423
  const handleRequestChanges = async (branchNameForChanges) => {
275
424
  options.setBusy(true);
@@ -279,6 +428,7 @@ export function useBranchManager(options) {
279
428
  throw new Error(result.error || 'Failed to request changes');
280
429
  }
281
430
  notifications.show({ message: 'Changes requested', color: 'orange' });
431
+ updateCreatedBranch(branchNameForChanges, result.data?.branch);
282
432
  await loadBranches();
283
433
  }
284
434
  catch (err) {
@@ -289,9 +439,10 @@ export function useBranchManager(options) {
289
439
  options.setBusy(false);
290
440
  }
291
441
  };
292
- const handleDelete = async (branchNameToDelete) => {
293
- return new Promise((resolve) => {
294
- showDeleteConfirmation(branchNameToDelete, async () => {
442
+ const handleDelete = (requested) => {
443
+ const branchNameToDelete = listedName(requested);
444
+ return singleFlight('delete', branchNameToDelete, () => new Promise((resolve) => {
445
+ showDeleteConfirmation(branchNameToDelete, pullRequestOf(branchNameToDelete), async () => {
295
446
  options.setBusy(true);
296
447
  try {
297
448
  const result = await apiClient.branches.delete({
@@ -300,8 +451,25 @@ export function useBranchManager(options) {
300
451
  if (!result.ok) {
301
452
  throw new Error(result.error || 'Failed to delete branch');
302
453
  }
303
- notifications.show({ message: 'Branch deleted', color: 'green' });
304
- await loadBranches();
454
+ const cleanupWarning = result.data?.cleanupWarning;
455
+ notifications.show(cleanupWarning
456
+ ? {
457
+ message: `Branch deleted, with a warning: ${cleanupWarning}`,
458
+ color: 'yellow',
459
+ autoClose: false,
460
+ }
461
+ : { message: 'Branch deleted', color: 'green' });
462
+ forgetCreatedBranch(branchNameToDelete);
463
+ const fresh = await reloadBranches();
464
+ // Matched the way `currentBranch` resolves a name, since the deleted branch is
465
+ // no longer listed to resolve against.
466
+ const open = latest.current.branchName;
467
+ if (open === branchNameToDelete ||
468
+ sanitizeBranchName(open) === branchNameToDelete) {
469
+ // Nothing is left to keep, so no unsaved-changes prompt. With no default known
470
+ // yet, the empty name lets the default-branch adoption effect pick it.
471
+ setBranchName(fresh?.defaultBranch ?? latest.current.branchesData?.defaultBranch ?? '');
472
+ }
305
473
  }
306
474
  catch (err) {
307
475
  const message = err instanceof Error ? err.message : 'Failed to delete branch';
@@ -309,13 +477,11 @@ export function useBranchManager(options) {
309
477
  }
310
478
  finally {
311
479
  options.setBusy(false);
312
- // Resolve regardless of API success/failure, matching the
313
- // pre-confirmation behavior (errors were shown as a toast, not
314
- // rethrown). Cancellation resolves too -- see below.
480
+ // Failures surface as the toast above, never as a rejection; cancelling resolves too.
315
481
  resolve();
316
482
  }
317
483
  }, () => resolve());
318
- });
484
+ }));
319
485
  };
320
486
  const handleReloadBranchData = async () => {
321
487
  await loadBranches();
@@ -338,6 +504,7 @@ export function useBranchManager(options) {
338
504
  branches,
339
505
  branchSummaries,
340
506
  currentBranch,
507
+ addCreatedBranch,
341
508
  handleSubmit,
342
509
  handleWithdraw,
343
510
  handleRequestChanges,
@@ -19,6 +19,12 @@ export declare const BRANCHES_KEY = "canopy:branches";
19
19
  export interface BranchesData {
20
20
  branches: BranchListItem[];
21
21
  defaultBranch?: string;
22
+ /**
23
+ * Client clock when this listing arrived. It makes listings fetched at
24
+ * different moments distinct `data` values even when their branches match,
25
+ * while a failed fetch (SWR keeps the previous `data`) changes nothing.
26
+ */
27
+ receivedAt: number;
22
28
  }
23
29
  /**
24
30
  * Fetch the branches list. Exported for direct testing and for the