From 46de5b31031d6e767def78b38df8c290ad2a0946 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 04:57:44 +0900 Subject: [PATCH 1/4] fix(gui): recover Aside overwrite previews instead of failing silently The Integrations Overview sent an aggregate Aside preview with no profile, which the server must refuse because a plan describes one profile's file. Conflicted Aside cards now route to the per-profile surface, where Replace is scoped and bound. A preview that answers 409 integration_preview_unavailable no longer closes the dialog. The Overview, the file page, and the Aside profile page keep it open and offer "Load catalog and retry", which performs the ordinary integration read that retains the export roster and then re-asks for the plan. Preview itself stays side-effect free. Closes #8354 --- .../src/content/docs/guides/integrations.md | 14 +- gui/src/i18n/de.ts | 4 + gui/src/i18n/en.ts | 4 + gui/src/i18n/fr.ts | 4 + gui/src/i18n/ja.ts | 4 + gui/src/i18n/ko.ts | 4 + gui/src/i18n/pt.ts | 4 + gui/src/i18n/ru.ts | 4 + gui/src/i18n/tr.ts | 4 + gui/src/i18n/vi.ts | 4 + gui/src/i18n/zh-TW.ts | 4 + gui/src/i18n/zh.ts | 4 + .../pages/integrations/AsideProfilesPage.tsx | 44 ++- .../pages/integrations/ConsequenceDialog.tsx | 30 ++ .../integrations/FileIntegrationPage.tsx | 55 +++- .../integrations/IntegrationsOverview.tsx | 74 ++++- .../pages/integrations/preview-recovery.ts | 21 ++ .../integrations-preview-recovery.test.tsx | 268 ++++++++++++++++++ 18 files changed, 512 insertions(+), 38 deletions(-) create mode 100644 gui/src/pages/integrations/preview-recovery.ts create mode 100644 gui/tests/integrations-preview-recovery.test.tsx diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 2463c8263ac..2a782ad8fc2 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -479,11 +479,15 @@ though the managed document does not change. If the file changes after you review it, the write is refused as stale. The dialog replaces the old plan with the updated one and asks you to confirm again; it never retries the write -automatically. If a preview is temporarily unavailable, reload the page normally and start the -action again. - -Aside uses the same preview and confirmation flow for one selected profile at a time. **Sync all -profiles** remains a separate bulk action and is not bound to one combined preview. +automatically. When a preview reports that no model catalog is loaded yet, the dialog stays open and +offers **Load catalog and retry**: it reads the integration list again — the ordinary load that +retains the catalog, never a preview that gathers models — and then asks for the plan once more. + +Aside uses the same preview and confirmation flow for one selected profile at a time, and a plan +describes exactly that profile's file. Because one plan cannot describe several independently +changing files, the Overview card for a conflicted Aside integration opens **Connect → Aside** with +**Choose profile** rather than a Replace action; replace the block on the profile's own page. **Sync +all profiles** remains a separate bulk action and is not bound to one combined preview. ## What to expect, honestly diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index bbf162a96cc..ed5b9f34348 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1985,6 +1985,10 @@ export const de: Record = { "integrations.preview.loading": "Der genaue Änderungsplan wird geladen…", "integrations.preview.failed": "Der Änderungsplan konnte nicht geladen werden. Es wurde nichts geändert.", "integrations.preview.stale": "Die Integration wurde während der Bestätigung geändert. Prüfe den aktualisierten Plan und bestätige erneut.", + "integrations.preview.catalogUnavailable": "Der Modellkatalog ist noch nicht geladen, daher kann diese Änderung nicht geplant werden. Lade ihn, um fortzufahren.", + "integrations.preview.loadCatalogRetry": "Katalog laden und erneut versuchen", + "integrations.preview.recovering": "Katalog wird geladen…", + "integrations.action.chooseProfile": "Profil wählen", "integrations.dialog.apply.title": "Diese Integration anwenden?", "integrations.dialog.apply.changes": "opencodex fügt der Client-Konfiguration seine verwalteten Anbieter-Einstellungen hinzu.", "integrations.dialog.apply.breakage": "Der Client verwendet die Modelle, die diese opencodex-Instanz derzeit bereitstellt.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 8b2f5ae6e5e..1ea33392fd2 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2623,6 +2623,10 @@ export const en = { "integrations.preview.loading": "Loading the exact change plan…", "integrations.preview.failed": "The change plan could not be loaded. Nothing was changed.", "integrations.preview.stale": "The integration changed while you were confirming. Review the updated plan and confirm again.", + "integrations.preview.catalogUnavailable": "The model catalog is not loaded yet, so this change cannot be planned. Load it to continue.", + "integrations.preview.loadCatalogRetry": "Load catalog and retry", + "integrations.preview.recovering": "Loading catalog…", + "integrations.action.chooseProfile": "Choose profile", "integrations.dialog.apply.title": "Apply this integration?", "integrations.dialog.apply.changes": "opencodex will add its managed provider settings to the client configuration.", "integrations.dialog.apply.breakage": "The client will use the models currently exposed by this opencodex instance.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 87d5a25c97f..91d0836989c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -2550,6 +2550,10 @@ export const fr: Record = { "integrations.preview.loading": "Chargement du plan de modification exact…", "integrations.preview.failed": "Le plan de modification n’a pas pu être chargé. Rien n’a été modifié.", "integrations.preview.stale": "L’intégration a changé pendant la confirmation. Vérifiez le plan mis à jour et confirmez à nouveau.", + "integrations.preview.catalogUnavailable": "Le catalogue de modèles n’est pas encore chargé, cette modification ne peut donc pas être planifiée. Chargez-le pour continuer.", + "integrations.preview.loadCatalogRetry": "Charger le catalogue et réessayer", + "integrations.preview.recovering": "Chargement du catalogue…", + "integrations.action.chooseProfile": "Choisir un profil", "integrations.dialog.apply.title": "Appliquer cette intégration ?", "integrations.dialog.apply.changes": "opencodex ajoutera ses paramètres de fournisseur gérés à la configuration du client.", "integrations.dialog.apply.breakage": "Le client utilisera les modèles actuellement exposés par cette instance opencodex.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index a4c60cff6b7..17e46d6e3d4 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -2445,6 +2445,10 @@ export const ja: Record = { "integrations.preview.loading": "正確な変更プランを読み込んでいます…", "integrations.preview.failed": "変更プランを読み込めませんでした。変更は行われていません。", "integrations.preview.stale": "確認中に統合の状態が変わりました。更新されたプランを確認して、もう一度確定してください。", + "integrations.preview.catalogUnavailable": "モデルカタログがまだ読み込まれていないため、この変更を計画できません。読み込んでから続行してください。", + "integrations.preview.loadCatalogRetry": "カタログを読み込んで再試行", + "integrations.preview.recovering": "カタログを読み込み中…", + "integrations.action.chooseProfile": "プロファイルを選択", "integrations.dialog.apply.title": "この統合を適用しますか?", "integrations.dialog.apply.changes": "opencodex が管理するプロバイダー設定をクライアント設定に追加します。", "integrations.dialog.apply.breakage": "クライアントは、この opencodex インスタンスが現在公開しているモデルを使用します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index acd5ffb8064..411fa9390f4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -2029,6 +2029,10 @@ export const ko: Record = { "integrations.preview.loading": "정확한 변경 계획을 불러오는 중…", "integrations.preview.failed": "변경 계획을 불러오지 못했습니다. 아무것도 변경하지 않았습니다.", "integrations.preview.stale": "확인하는 동안 통합 상태가 바뀌었습니다. 갱신된 계획을 검토하고 다시 확인하세요.", + "integrations.preview.catalogUnavailable": "모델 카탈로그가 아직 로드되지 않아 이 변경을 계획할 수 없습니다. 계속하려면 카탈로그를 로드하세요.", + "integrations.preview.loadCatalogRetry": "카탈로그 로드 후 다시 시도", + "integrations.preview.recovering": "카탈로그 로드 중…", + "integrations.action.chooseProfile": "프로필 선택", "integrations.dialog.apply.title": "이 통합을 적용할까요?", "integrations.dialog.apply.changes": "opencodex가 관리하는 공급자 설정을 클라이언트 구성에 추가합니다.", "integrations.dialog.apply.breakage": "클라이언트는 현재 이 opencodex 인스턴스가 제공하는 모델을 사용합니다.", diff --git a/gui/src/i18n/pt.ts b/gui/src/i18n/pt.ts index 191d243a22d..0f0f9742c19 100644 --- a/gui/src/i18n/pt.ts +++ b/gui/src/i18n/pt.ts @@ -2593,6 +2593,10 @@ export const pt: Record = { "integrations.preview.loading": "Carregando o plano exato de alterações…", "integrations.preview.failed": "Não foi possível carregar o plano de alterações. Nada foi alterado.", "integrations.preview.stale": "A integração mudou enquanto você confirmava. Revise o plano atualizado e confirme novamente.", + "integrations.preview.catalogUnavailable": "O catálogo de modelos ainda não foi carregado, então esta alteração não pode ser planejada. Carregue-o para continuar.", + "integrations.preview.loadCatalogRetry": "Carregar catálogo e tentar novamente", + "integrations.preview.recovering": "Carregando catálogo…", + "integrations.action.chooseProfile": "Escolher perfil", "integrations.dialog.apply.title": "Aplicar esta integração?", "integrations.dialog.apply.changes": "O opencodex vai adicionar suas configurações de provedor gerenciadas à configuração do cliente.", "integrations.dialog.apply.breakage": "O cliente passará a usar os modelos expostos atualmente por esta instância do opencodex.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 16cc7a8f665..0926d4a95c6 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -2536,6 +2536,10 @@ export const ru: Record = { "integrations.preview.loading": "Загрузка точного плана изменений…", "integrations.preview.failed": "Не удалось загрузить план изменений. Ничего не изменено.", "integrations.preview.stale": "Интеграция изменилась во время подтверждения. Проверьте обновлённый план и подтвердите снова.", + "integrations.preview.catalogUnavailable": "Каталог моделей ещё не загружен, поэтому это изменение нельзя спланировать. Загрузите его, чтобы продолжить.", + "integrations.preview.loadCatalogRetry": "Загрузить каталог и повторить", + "integrations.preview.recovering": "Загрузка каталога…", + "integrations.action.chooseProfile": "Выбрать профиль", "integrations.dialog.apply.title": "Применить эту интеграцию?", "integrations.dialog.apply.changes": "opencodex добавит управляемые настройки провайдера в конфигурацию клиента.", "integrations.dialog.apply.breakage": "Клиент будет использовать модели, доступные через этот экземпляр opencodex.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 9c9f8f938b8..1a189d5fb60 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -2563,6 +2563,10 @@ export const tr: Record = { "integrations.preview.loading": "Kesin değişiklik planı yükleniyor…", "integrations.preview.failed": "Değişiklik planı yüklenemedi. Hiçbir şey değiştirilmedi.", "integrations.preview.stale": "Onay sırasında entegrasyon değişti. Güncellenen planı inceleyip yeniden onaylayın.", + "integrations.preview.catalogUnavailable": "Model kataloğu henüz yüklenmediği için bu değişiklik planlanamıyor. Devam etmek için kataloğu yükleyin.", + "integrations.preview.loadCatalogRetry": "Kataloğu yükle ve yeniden dene", + "integrations.preview.recovering": "Katalog yükleniyor…", + "integrations.action.chooseProfile": "Profil seç", "integrations.dialog.apply.title": "Bu entegrasyon uygulansın mı?", "integrations.dialog.apply.changes": "opencodex, yönettiği sağlayıcı ayarlarını istemci yapılandırmasına ekleyecek.", "integrations.dialog.apply.breakage": "İstemci, bu opencodex örneğinin sunduğu modelleri kullanacak.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 87530280e12..1c505821738 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -2571,6 +2571,10 @@ export const vi: Record = { "integrations.preview.loading": "Đang tải kế hoạch thay đổi chính xác…", "integrations.preview.failed": "Không thể tải kế hoạch thay đổi. Không có gì bị thay đổi.", "integrations.preview.stale": "Tích hợp đã thay đổi trong lúc xác nhận. Hãy xem kế hoạch mới và xác nhận lại.", + "integrations.preview.catalogUnavailable": "Danh mục mô hình chưa được tải nên không thể lập kế hoạch cho thay đổi này. Hãy tải danh mục để tiếp tục.", + "integrations.preview.loadCatalogRetry": "Tải danh mục và thử lại", + "integrations.preview.recovering": "Đang tải danh mục…", + "integrations.action.chooseProfile": "Chọn hồ sơ", "integrations.dialog.apply.title": "Áp dụng tích hợp này?", "integrations.dialog.apply.changes": "opencodex sẽ thêm các cài đặt nhà cung cấp do nó quản lý vào cấu hình máy khách.", "integrations.dialog.apply.breakage": "Máy khách sẽ dùng các mô hình hiện được phiên bản opencodex này cung cấp.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 1e9e42176be..d25d4f5759b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -3505,6 +3505,10 @@ export const zhTW: Record = { "integrations.preview.loading": "正在載入精確的變更計畫…", "integrations.preview.failed": "無法載入變更計畫。未進行任何變更。", "integrations.preview.stale": "確認期間整合狀態已變更。請檢視更新後的計畫並再次確認。", + "integrations.preview.catalogUnavailable": "模型目錄尚未載入,無法規劃此變更。請載入後繼續。", + "integrations.preview.loadCatalogRetry": "載入目錄並重試", + "integrations.preview.recovering": "正在載入目錄…", + "integrations.action.chooseProfile": "選擇設定檔", "integrations.dialog.apply.title": "套用此整合?", "integrations.dialog.apply.changes": "opencodex 會將其管理的供應商設定加入用戶端設定。", "integrations.dialog.apply.breakage": "用戶端將使用此 opencodex 執行個體目前提供的模型。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index b988a709ecb..ec19c16c3c2 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -2002,6 +2002,10 @@ export const zh: Record = { "integrations.preview.loading": "正在加载准确的更改计划…", "integrations.preview.failed": "无法加载更改计划。未进行任何更改。", "integrations.preview.stale": "确认期间集成状态已变化。请查看更新后的计划并再次确认。", + "integrations.preview.catalogUnavailable": "模型目录尚未加载,无法规划此更改。请加载后继续。", + "integrations.preview.loadCatalogRetry": "加载目录并重试", + "integrations.preview.recovering": "正在加载目录…", + "integrations.action.chooseProfile": "选择配置文件", "integrations.dialog.apply.title": "应用此集成?", "integrations.dialog.apply.changes": "opencodex 会将其管理的提供商设置添加到客户端配置。", "integrations.dialog.apply.breakage": "客户端将使用此 opencodex 实例当前提供的模型。", diff --git a/gui/src/pages/integrations/AsideProfilesPage.tsx b/gui/src/pages/integrations/AsideProfilesPage.tsx index c7981995379..c43da82753a 100644 --- a/gui/src/pages/integrations/AsideProfilesPage.tsx +++ b/gui/src/pages/integrations/AsideProfilesPage.tsx @@ -10,11 +10,12 @@ import FileIntegrationPage from "./FileIntegrationPage"; import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import { describeRefusal, describeAsideProfileOutcome } from "./refusal-copy"; import type { AsideProfileOutcome } from "./aside-profile-contract"; +import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery"; import { bindingFor, IntegrationApiError, - isIntegrationPreviewUnavailable, previewIntegrationMutation, + loadIntegrationState, toggleIntegration, type IntegrationMutationPlan, } from "./integration-api"; @@ -44,6 +45,7 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: plan: IntegrationMutationPlan | null; loading: boolean; failure: string | null; + recovery: PreviewRecovery | null; } | null>(null); const previewAbortRef = useRef(null); const previewGenerationRef = useRef(0); @@ -104,29 +106,47 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: setPending(null); } }; - const requestProfileMutation = async (profile: AsideProfileStatus, enabled: boolean) => { - if (pendingRef.current || planned) return; + const requestProfileMutation = async ( + profile: AsideProfileStatus, + enabled: boolean, + /** Recovery replaces the failed plan it is retrying; the first request never does. */ + replacing = false, + ) => { + if (pendingRef.current || (planned && !replacing)) return; const controller = new AbortController(); const generation = previewGenerationRef.current + 1; previewGenerationRef.current = generation; previewAbortRef.current?.abort(); previewAbortRef.current = controller; - setPlanned({ profile, enabled, plan: null, loading: true, failure: null }); + setPlanned({ profile, enabled, plan: null, loading: true, failure: null, recovery: null }); try { const plan = await previewIntegrationMutation(apiBase, "aside", enabled ? "apply" : "disable", controller.signal, profile.profileId); if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - setPlanned({ profile, enabled, plan, loading: false, failure: null }); + setPlanned({ profile, enabled, plan, loading: false, failure: null, recovery: null }); } catch (error) { if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - if (isIntegrationPreviewUnavailable(error)) { - setPlanned(null); - resource.refresh(); - return; - } - setPlanned({ profile, enabled, plan: null, loading: false, failure: t("integrations.preview.failed") }); + // A missing retained roster is recoverable: keep the dialog and offer the + // ordinary profile read that fills it, so the retry can produce a plan. + const recovery = previewRecoveryFor(error); + setPlanned({ + profile, enabled, plan: null, loading: false, recovery, + failure: recovery === null ? t("integrations.preview.failed") : null, + }); + } + }; + const recoverPlanned = async () => { + const pendingPlan = planned; + if (!pendingPlan) return; + try { + await loadIntegrationState(apiBase, "aside", undefined, pendingPlan.profile.profileId); + } catch { + // Fall through: the preview answers again, and a second banner here + // would only hide the refusal the user can act on. } + resource.refresh(); + await requestProfileMutation(pendingPlan.profile, pendingPlan.enabled, true); }; const closePlanned = () => { previewGenerationRef.current += 1; @@ -235,6 +255,8 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: plan={planned.plan} planLoading={planned.loading} planFailure={planned.failure} + previewRecovery={planned.recovery} + onPreviewRecovery={recoverPlanned} onClose={closePlanned} onConfirm={async plan => { if (!plan) return; diff --git a/gui/src/pages/integrations/ConsequenceDialog.tsx b/gui/src/pages/integrations/ConsequenceDialog.tsx index 94b964ee2f2..e80891fb02c 100644 --- a/gui/src/pages/integrations/ConsequenceDialog.tsx +++ b/gui/src/pages/integrations/ConsequenceDialog.tsx @@ -3,6 +3,7 @@ import { useT, type TKey } from "../../i18n/shared"; import { Notice } from "../../ui"; import IntegrationPlanDetails, { type LabeledIntegrationPlan } from "./IntegrationPlanDetails"; import { IntegrationApiError, type IntegrationMutationPlan } from "./integration-api"; +import type { PreviewRecovery } from "./preview-recovery"; export interface ConsequenceCopy { titleKey: TKey; @@ -35,6 +36,8 @@ export default function ConsequenceDialog({ planStale = false, planLoading = false, planFailure = null, + previewRecovery = null, + onPreviewRecovery, missingStorePath, onConfirm, onClose, @@ -46,6 +49,13 @@ export default function ConsequenceDialog({ planStale?: boolean; planLoading?: boolean; planFailure?: string | null; + /** + * A preview that could not be made for a reason the user can clear by reading + * the page's data again (no retained model roster). The dialog then shows the + * reason and one explicit button instead of a failure the user can only stare at. + */ + previewRecovery?: PreviewRecovery | null; + onPreviewRecovery?: () => Promise | void; /** Passed through to the plan details; see IntegrationPlanDetails. */ missingStorePath?: string; onConfirm: (plan?: IntegrationMutationPlan) => Promise | void; @@ -57,6 +67,7 @@ export default function ConsequenceDialog({ const closeRef = useRef(null); const [pending, setPending] = useState(false); const [failure, setFailure] = useState(null); + const [recovering, setRecovering] = useState(false); const [staleOverride, setStaleOverride] = useState<{ sourceFingerprint: string | null; plan: IntegrationMutationPlan; @@ -115,6 +126,19 @@ export default function ConsequenceDialog({ } }, [activePlan, onConfirm, pending, plan?.fingerprint, t]); + // Recovery is its own press, and its own busy state: it must not look like a + // confirmation, and it must not leave the confirm button enabled while the + // plan it would confirm is still missing. + const recoverPreview = useCallback(async () => { + if (!onPreviewRecovery || recovering) return; + setRecovering(true); + try { + await onPreviewRecovery(); + } finally { + setRecovering(false); + } + }, [onPreviewRecovery, recovering]); + const slots: ReactNode[] = [ , , @@ -151,11 +175,17 @@ export default function ConsequenceDialog({ {planLoading &&

{t("integrations.preview.loading")}

} {pending &&

{t("integrations.mutation.pending")}

} {(stale || planStale) && {t("integrations.preview.stale")}} + {previewRecovery === "catalog-unavailable" && {t("integrations.preview.catalogUnavailable")}} {planFailure && {planFailure}} {failure && {failure}}
+ {previewRecovery !== null && onPreviewRecovery && ( + + )} diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index 51f78320e64..a489fbc3ee8 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -12,6 +12,7 @@ import RaycastPlanNotice from "./RaycastPlanNotice"; import DroidReasoningDefaultsPanel from "./DroidReasoningDefaultsPanel"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; +import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery"; import { loadIntegrationJournal, loadIntegrationState, @@ -21,7 +22,6 @@ import { bindingFor, IntegrationApiError, deleteJournalEntry, - isIntegrationPreviewUnavailable, isMissingJournalEntry, type FileIntegrationClientId, type IntegrationJournalRow, @@ -165,6 +165,7 @@ function FileIntegrationControls({ plan: IntegrationMutationPlan | null; loading: boolean; failure: string | null; + recovery: PreviewRecovery | null; droidReasoningDefaults?: Record; } | null>(null); const previewAbortRef = useRef(null); @@ -229,32 +230,64 @@ function FileIntegrationControls({ ? droidReasoningDraft.values : undefined) : undefined, + /** Recovery replaces the failed plan it is retrying; the first request never does. */ + replacing = false, ) => { - if (!status || pending || plannedMutation) return; + if (!status || pending || (plannedMutation && !replacing)) return; const defaultsSnapshot = reasoningDefaults === undefined ? undefined : { ...reasoningDefaults }; const controller = new AbortController(); const generation = previewGenerationRef.current + 1; previewGenerationRef.current = generation; previewAbortRef.current?.abort(); previewAbortRef.current = controller; - setPlannedMutation({ operation, plan: null, loading: true, failure: null }); + setPlannedMutation({ operation, plan: null, loading: true, failure: null, recovery: null }); try { const plan = await previewIntegrationMutation(apiBase, client, operation, controller.signal, profileId, defaultsSnapshot, hermesProfile); if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - setPlannedMutation({ operation, plan, loading: false, failure: null, droidReasoningDefaults: defaultsSnapshot }); + setPlannedMutation({ operation, plan, loading: false, failure: null, recovery: null, droidReasoningDefaults: defaultsSnapshot }); } catch (error) { if (controller.signal.aborted || generation !== previewGenerationRef.current) return; previewAbortRef.current = null; - if (isIntegrationPreviewUnavailable(error)) { - setPlannedMutation(null); - refresh(); - return; - } - setPlannedMutation({ operation, plan: null, loading: false, failure: t("integrations.preview.failed") }); + // A missing retained roster is recoverable from this dialog. Closing it, + // as this used to, left the user with a button that appeared to do + // nothing at all; the preview itself never gathers models. + const recovery = previewRecoveryFor(error); + setPlannedMutation({ + operation, plan: null, loading: false, recovery, + failure: recovery === null ? t("integrations.preview.failed") : null, + }); } }; + /* + * Re-read this surface's own data, then ask for the plan again. + * + * The state resource refreshes through the ordinary profile/client read, and the + * server's integration reads are the loads that retain an export roster. No new + * endpoint and no write: the preview stays a read, and the retry only repeats + * the question now that an answer exists. + */ + const recoverPlannedMutation = async () => { + const pendingOperation = plannedMutation; + if (!pendingOperation) return; + const defaults = pendingOperation.droidReasoningDefaults; + /* + * This read IS the recovery: the server retains its export roster from an + * ordinary integration load, so waiting for this one is what makes the + * next preview answerable. The resource refresh below only repaints the + * surface with the same data; it cannot be awaited for the answer. + */ + try { + await loadIntegrationState(apiBase, client, undefined, profileId, hermesProfile); + } catch { + // A failed read is not a plan; fall through and let the preview say so + // again rather than reporting a second, unrelated error. + } + refresh(); + await requestMutation(pendingOperation.operation, defaults, true); + }; + const closePlannedMutation = () => { previewGenerationRef.current += 1; previewAbortRef.current?.abort(); @@ -503,6 +536,8 @@ function FileIntegrationControls({ plan={plannedMutation.plan} planLoading={plannedMutation.loading} planFailure={plannedMutation.failure} + previewRecovery={plannedMutation.recovery} + onPreviewRecovery={recoverPlannedMutation} missingStorePath={status.supersededReason === "missing-store" ? status.supersededBy : undefined} onClose={closePlannedMutation} onConfirm={async plan => { if (plan) await mutate(plan); }} diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index ee8c136ace4..ac27b2a0454 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -12,6 +12,7 @@ import ConsequenceDialog, { type ConsequenceCopy } from "./ConsequenceDialog"; import RestoreDialog from "./RestoreDialog"; import { RollbackHistory } from "./RollbackHistory"; import { describeRefusal } from "./refusal-copy"; +import { previewRecoveryFor, type PreviewRecovery } from "./preview-recovery"; import { loadCursorIntegrationStatus } from "./cursor-api"; import { buildOverviewRows, @@ -34,7 +35,6 @@ import { bindingFor, deleteJournalEntry, IntegrationApiError, - isIntegrationPreviewUnavailable, isMissingJournalEntry, type IntegrationJournalRow, type IntegrationStatus, @@ -144,6 +144,7 @@ function OverviewCard({ onOpen, onToggle, onOverwrite, + onChooseProfile, }: { row: OverviewRow; pending: boolean; @@ -152,6 +153,8 @@ function OverviewCard({ onToggle: (() => void) | null; /** Present only for a conflicted file client; null everywhere else. */ onOverwrite: (() => void) | null; + /** Aside only: one plan names one profile, so the aggregate card cannot replace. */ + onChooseProfile: (() => void) | null; }) { const t = useT(); const detail = row.detail ?? (row.detailKey ? t(row.detailKey, row.detailVars ?? undefined) : null); @@ -235,6 +238,17 @@ function OverviewCard({ {t("integrations.action.overwrite")} )} + {/* + Aside has no aggregate writer: a fingerprint describes one profile's + file, and the server refuses an unprofiled plan for exactly that + reason. The card therefore sends the user to the per-profile surface + instead of opening a dialog that could never load a plan. + */} + {onChooseProfile && ( + + )}
); @@ -273,6 +287,7 @@ export default function IntegrationsOverview({ plan: IntegrationMutationPlan | null; loading: boolean; failure: string | null; + recovery: PreviewRecovery | null; } | null>(null); const [bulkPlans, setBulkPlans] = useState(null); const cardPreviewAbortRef = useRef(null); @@ -648,31 +663,61 @@ export default function IntegrationsOverview({ } }; - const requestFilePlan = async (row: OverviewRow, operation: Exclude) => { - if (!row.status || plannedCard) return; + const requestFilePlan = async ( + row: OverviewRow, + operation: Exclude, + /** Recovery replaces the failed plan it is retrying; the first request never does. */ + replacing = false, + ) => { + if (!row.status || (plannedCard && !replacing)) return; const controller = new AbortController(); const generation = cardPreviewGenerationRef.current + 1; cardPreviewGenerationRef.current = generation; cardPreviewAbortRef.current?.abort(); cardPreviewAbortRef.current = controller; - setPlannedCard({ row, operation, plan: null, loading: true, failure: null }); + setPlannedCard({ row, operation, plan: null, loading: true, failure: null, recovery: null }); try { const plan = await previewIntegrationMutation(apiBase, row.status.clientId, operation, controller.signal); if (controller.signal.aborted || generation !== cardPreviewGenerationRef.current) return; cardPreviewAbortRef.current = null; - setPlannedCard({ row, operation, plan, loading: false, failure: null }); + setPlannedCard({ row, operation, plan, loading: false, failure: null, recovery: null }); } catch (error) { if (controller.signal.aborted || generation !== cardPreviewGenerationRef.current) return; cardPreviewAbortRef.current = null; - if (isIntegrationPreviewUnavailable(error)) { - setPlannedCard(null); - refresh(); - return; - } - setPlannedCard({ row, operation, plan: null, loading: false, failure: t("integrations.preview.failed") }); + /* + * No retained roster is NOT a dead end. Closing the dialog, as this used + * to, looked like the button did nothing; keep the dialog and offer the + * one read that makes the next preview answerable. + */ + const recovery = previewRecoveryFor(error); + setPlannedCard({ + row, operation, plan: null, loading: false, recovery, + failure: recovery === null ? t("integrations.preview.failed") : null, + }); } }; + /* + * Read the collection again, then re-ask for the plan. + * + * The collection read is an ordinary authoritative load and is what retains + * the export roster the preview reads. Waiting for it — rather than the + * fire-and-forget refresh() — is the whole recovery; nothing here writes a + * client file, and the preview stays side-effect free. + */ + const recoverPlannedCard = async () => { + const planned = plannedCard; + if (!planned) return; + try { + await loadIntegrationStates(apiBase); + } catch { + // The preview will answer (or refuse) again; a second error banner here + // would only obscure the one the user can act on. + } + refresh(); + await requestFilePlan(planned.row, planned.operation, true); + }; + const closePlannedCard = () => { cardPreviewGenerationRef.current += 1; cardPreviewAbortRef.current?.abort(); @@ -720,9 +765,12 @@ export default function IntegrationsOverview({ result={cardResults[row.id] ?? null} onOpen={() => navigateHash(row.hash)} onToggle={row.toggle ? () => requestToggle(row, !(row.toggleOn ?? row.applied)) : null} - onOverwrite={row.status !== null && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed + onOverwrite={row.status !== null && row.status.clientId !== "aside" && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed ? () => void requestFilePlan(row, "overwrite") : null} + onChooseProfile={row.status?.clientId === "aside" && row.status.state === "conflict" && row.status.reason !== "candidate-conflict" && row.installed + ? () => navigateHash(row.hash) + : null} /> ); @@ -938,6 +986,8 @@ export default function IntegrationsOverview({ plan={plannedCard.plan} planLoading={plannedCard.loading} planFailure={plannedCard.failure} + previewRecovery={plannedCard.recovery} + onPreviewRecovery={recoverPlannedCard} onClose={closePlannedCard} onConfirm={async plan => { if (!plan) return; diff --git a/gui/src/pages/integrations/preview-recovery.ts b/gui/src/pages/integrations/preview-recovery.ts new file mode 100644 index 00000000000..2fbf7510bbf --- /dev/null +++ b/gui/src/pages/integrations/preview-recovery.ts @@ -0,0 +1,21 @@ +import { isIntegrationPreviewUnavailable } from "./integration-api"; + +/** + * What a preview that answered 409 `integration_preview_unavailable` means, and the one + * action that can honestly fix it. + * + * The server refuses rather than gathering, because a preview that discovered models would + * refresh credentials and write the provider cache — a write wearing a read's name. The + * retained roster is filled by an ORDINARY load instead. On the dashboard that load is the + * same collection read the Integrations page already performs on mount, so the recovery is + * "read the page's own data again, then plan": no new write path, and the preview stays a + * read. + * + * `null` means the error is not this refusal, so the caller should surface its ordinary + * failure copy. + */ +export type PreviewRecovery = "catalog-unavailable"; + +export function previewRecoveryFor(error: unknown): PreviewRecovery | null { + return isIntegrationPreviewUnavailable(error) ? "catalog-unavailable" : null; +} diff --git a/gui/tests/integrations-preview-recovery.test.tsx b/gui/tests/integrations-preview-recovery.test.tsx new file mode 100644 index 00000000000..84cb4b495db --- /dev/null +++ b/gui/tests/integrations-preview-recovery.test.tsx @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { ReactElement } from "react"; +import type { Root } from "react-dom/client"; + +/** + * Two ways a reported-conflict action could not produce a plan. + * + * 1. The Overview's Replace button for Aside sent an aggregate + * `/api/client-integrations/preview` with no profile, which the server must + * refuse: a plan describes one independently changing file. The Aside + * surface already owns per-profile conflicts, so the card's action belongs + * there rather than in a dead dialog. + * 2. A preview answers 409 `integration_preview_unavailable` when no export + * roster has been retained yet, or when a provider cache revision retired + * it. The Overview used to close the dialog and leave the user with a + * button that appeared to do nothing. Recovery has to be explicit: an + * ordinary collection read gathers the roster (the same load the page + * already performs), and only then is the preview asked again. The preview + * itself stays side-effect free. + */ + +const globals = [ + "document", "window", "navigator", "localStorage", "sessionStorage", "fetch", "IS_REACT_ACT_ENVIRONMENT", +] as const; + +let previousGlobals: Record<(typeof globals)[number], PropertyDescriptor | undefined>; +let testWindow: Window; +let container: HTMLElement; +let root: Root | null = null; +let apiBase = ""; +type RecordedRequest = { path: string; method: string; body: unknown }; +let requests: RecordedRequest[]; +let clients: Record[]; +let preview: (body: Record) => Response; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +function fileStatus(overrides: Record = {}): Record { + return { + clientId: "hermes", state: "current", installed: true, + configPath: "/tmp/home/.hermes/config.yaml", snapshotCount: 1, retentionDegraded: false, + ...overrides, + }; +} + +function asideStatus(overrides: Record = {}): Record { + return fileStatus({ clientId: "aside", configPath: "/tmp/aside/u/1/models.json", ...overrides }); +} + +function plan(clientId: string, operation: string, extra: Record = {}) { + // The client parser accepts only a p:<32 lowercase hex> fingerprint. + const digit = ({ apply: "1", overwrite: "2", disable: "3", restore: "4" } as Record)[operation] ?? "9"; + return { + version: 1, clientId, operation, + state: operation === "apply" ? "absent" : "current", foreignEdit: "none", + changes: [ + { kind: operation === "disable" ? "remove" : "replace", path: "providers.opencodex" }, + { kind: "snapshot", path: "$snapshot" }, { kind: "ownership", path: "$ownership" }, + { kind: "journal", path: "$journal" }, + ], + fingerprint: `p1:${digit.repeat(32)}`, + canApply: true, willChange: true, + ...extra, + }; +} + +beforeEach(() => { + previousGlobals = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#integrations" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + sessionStorage: { configurable: true, value: testWindow.sessionStorage }, + IS_REACT_ACT_ENVIRONMENT: { configurable: true, writable: true, value: true }, + }); + requests = []; + apiBase = `http://ocx-preview-recovery-${Math.random().toString(36).slice(2)}.invalid`; + clients = [fileStatus()]; + preview = body => json(plan(String(body.clientId), String(body.operation))); + const mockFetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input instanceof Request ? input.url : input)); + const request = { + path: url.pathname + url.search, + method: (init?.method ?? "GET").toUpperCase(), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + requests.push(request); + if (request.method === "POST" && /\/preview$/.test(url.pathname)) return preview(request.body as Record); + if (url.pathname.includes("/journal")) return json({ operations: [] }); + if (url.pathname === "/api/client-integrations") return json({ clients }); + const profileState = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)$/); + if (profileState) { + const profileId = Number(profileState[1]); + return json(clients.find(row => row.profileId === profileId) ?? asideStatus({ profileId, current: false, enabled: true })); + } + if (url.pathname === "/api/startup-health") return json({ routingInjected: false, status: "native", recommendedCommand: null }); + if (url.pathname === "/api/keys") return json({ keys: [] }); + if (url.pathname === "/api/claude-desktop/status") { + return json({ desiredEnabled: true, installed: true, observedKind: "standard", applied: false, stale: false, activeProfile: null, appliedAt: null }); + } + if (url.pathname === "/api/native-integrations") return json({ clients: [] }); + if (url.pathname === "/api/claude-code") return json({ enabled: false }); + if (url.pathname === "/api/grok") return json({ present: false, models: [] }); + if (url.pathname === "/api/codex-agent-roles") return json({ lazycodex: { detected: false }, omoJsonc: null, roles: [] }); + if (url.pathname === "/api/subagent-models") return json({ available: [] }); + if (request.method === "PUT") { + const scoped = url.pathname.match(/^\/api\/client-integrations\/aside\/profiles\/(\d+)$/); + return scoped + ? json({ ok: true, clientId: "aside", profileId: Number(scoped[1]), changed: true, state: "current", message: "updated" }) + : json({ ok: true, clientId: "hermes", changed: true, state: "current", message: "updated" }); + } + return json({ error: `Unexpected request: ${request.path}` }, 404); + }) as typeof fetch; + Object.defineProperty(globalThis, "fetch", { configurable: true, value: mockFetch }); + Object.defineProperty(testWindow, "fetch", { configurable: true, value: mockFetch }); + container = testWindow.document.createElement("div") as unknown as HTMLElement; + testWindow.document.body.appendChild(container as never); +}); + +afterEach(async () => { + if (root) { + const mounted = root; + await act(async () => { mounted.unmount(); }); + root = null; + } + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobals[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function settle(ms = 40): Promise { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, ms)); }); +} + +async function mount(node: () => Promise<{ element: ReactElement; name: string }>): Promise { + const [{ createRoot }, { LanguageProvider }, loaded] = await Promise.all([ + import("react-dom/client"), import("../src/i18n/provider"), node(), + ]); + await act(async () => { + root = createRoot(container); + root.render({loaded.element}); + }); + await settle(); +} + +function buttons(): HTMLButtonElement[] { + return Array.from(container.querySelectorAll("button")); +} + +function buttonByText(text: string): HTMLButtonElement | undefined { + return buttons().find(button => (button.textContent ?? "").trim() === text); +} + +test("a conflicted Aside card routes to the per-profile surface instead of an unprofiled preview", async () => { + clients = [asideStatus({ state: "conflict", reason: "unowned-key" })]; + const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview"); + await mount(async () => ({ name: "overview", element: })); + + // The aggregate preview cannot describe one profile; the card must not try. + const action = buttonByText("Choose profile"); + expect(action).toBeDefined(); + expect(buttonByText("Replace")).toBeUndefined(); + + await act(async () => { action!.click(); }); + expect(testWindow.location.hash).toBe("#integrations/aside"); + expect(requests.filter(request => request.path.endsWith("/api/client-integrations/preview"))).toHaveLength(0); + expect(container.querySelector("dialog[open]")).toBeNull(); +}); + +test("an unavailable preview keeps the dialog open and retries after an ordinary catalog load", async () => { + clients = [fileStatus({ state: "conflict", reason: "unowned-key" })]; + let previews = 0; + let collections = 0; + let collectionsAtFirstPreview = -1; + preview = body => { + previews += 1; + if (previews === 1) { + collectionsAtFirstPreview = collections; + return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409); + } + if (collections <= collectionsAtFirstPreview) { + return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409); + } + return json(plan(String(body.clientId), String(body.operation))); + }; + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if ((init?.method ?? "GET").toUpperCase() === "GET" && url.endsWith("/api/client-integrations")) collections += 1; + return originalFetch(input, init); + }) as typeof fetch, + }); + Object.defineProperty(testWindow, "fetch", { configurable: true, value: globalThis.fetch }); + + const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview"); + await mount(async () => ({ name: "overview", element: })); + + await act(async () => { buttonByText("Replace")!.click(); }); + await settle(); + + const dialog = container.querySelector("dialog[open]"); + expect(dialog).not.toBeNull(); + const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined; + expect(retry).toBeDefined(); + expect(dialog!.textContent).toContain("model catalog is not loaded"); + + await act(async () => { retry!.click(); }); + await settle(); + + expect(collections).toBeGreaterThan(collectionsAtFirstPreview); + const confirmed = container.querySelector("dialog[open]")!; + expect(confirmed.textContent).toContain("Operation: Replace"); + const confirm = Array.from(confirmed.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement; + expect(confirm.disabled).toBe(false); + await act(async () => { confirm.click(); }); + await settle(); + expect(requests.find(request => request.method === "PUT")?.body).toEqual({ + enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("hermes", "overwrite").fingerprint, + }); +}); + +test("a per-profile Aside overwrite offers the same catalog retry and keeps its scope", async () => { + clients = [asideStatus({ profileId: 4, current: false, enabled: true, state: "conflict", reason: "foreign-edit" })]; + let previews = 0; + preview = body => { + previews += 1; + if (previews === 1) return json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409); + return json(plan("aside", String(body.operation), { profileId: 4 })); + }; + + const { default: FileIntegrationPage } = await import("../src/pages/integrations/FileIntegrationPage"); + await mount(async () => ({ name: "aside profile", element: })); + + const replace = buttonByText("Replace"); + expect(replace).toBeDefined(); + await act(async () => { replace!.click(); }); + await settle(); + + const dialog = container.querySelector("dialog[open]"); + expect(dialog).not.toBeNull(); + const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined; + expect(retry).toBeDefined(); + await act(async () => { retry!.click(); }); + await settle(); + + expect(container.querySelector("dialog[open]")?.textContent).toContain("Operation: Replace"); + const confirm = Array.from(container.querySelector("dialog[open]")!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement; + await act(async () => { confirm.click(); }); + await settle(); + expect(requests.find(request => request.method === "PUT")?.path).toBe("/api/client-integrations/aside/profiles/4"); + expect(requests.find(request => request.method === "PUT")?.body).toEqual({ + enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("aside", "overwrite", { profileId: 4 }).fingerprint, + }); +}); From 44c79608ee51c28f2f10bb6e9f3109ccba43a031 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 05:03:28 +0900 Subject: [PATCH 2/4] fix(gui): offer catalog recovery for bulk disable previews too Disable-all had the same silent close on integration_preview_unavailable. It now keeps its dialog and retries from the same ordinary collection read. Closes #8354 --- .../integrations/IntegrationsOverview.tsx | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index ac27b2a0454..6a561b76e54 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -110,6 +110,7 @@ interface BulkDisableState { actions: BulkDisableAction[]; loading: boolean; failure: string | null; + recovery: PreviewRecovery | null; stale: boolean; failures: string[]; } @@ -476,14 +477,17 @@ export default function IntegrationsOverview({ * single-client PUT the card uses, so every client gets its own snapshot and * its own journal row, and one refusal cannot silently swallow the rest. */ - const requestDisableAll = async () => { - if (bulkPending || bulkPlans || appliedClients.length === 0) return; + const requestDisableAll = async ( + /** Recovery replaces the failed plan set it is retrying; the first request never does. */ + replacing = false, + ) => { + if (bulkPending || (bulkPlans && !replacing) || appliedClients.length === 0) return; const controller = new AbortController(); const generation = bulkPreviewGenerationRef.current + 1; bulkPreviewGenerationRef.current = generation; bulkPreviewAbortRef.current?.abort(); bulkPreviewAbortRef.current = controller; - setBulkPlans({ actions: [], loading: true, failure: null, stale: false, failures: [] }); + setBulkPlans({ actions: [], loading: true, failure: null, recovery: null, stale: false, failures: [] }); try { const actions = await Promise.all(appliedClients.map(async client => { const row = rows.find(candidate => candidate.status?.clientId === client.clientId); @@ -495,19 +499,29 @@ export default function IntegrationsOverview({ })); if (controller.signal.aborted || generation !== bulkPreviewGenerationRef.current) return; bulkPreviewAbortRef.current = null; - setBulkPlans({ actions, loading: false, failure: null, stale: false, failures: [] }); + setBulkPlans({ actions, loading: false, failure: null, recovery: null, stale: false, failures: [] }); } catch (error) { if (controller.signal.aborted || generation !== bulkPreviewGenerationRef.current) return; bulkPreviewAbortRef.current = null; - if (isIntegrationPreviewUnavailable(error)) { - setBulkPlans(null); - refresh(); - return; - } - setBulkPlans({ actions: [], loading: false, failure: t("integrations.preview.failed"), stale: false, failures: [] }); + const recovery = previewRecoveryFor(error); + setBulkPlans({ + actions: [], loading: false, recovery, stale: false, failures: [], + failure: recovery === null ? t("integrations.preview.failed") : null, + }); } }; + const recoverBulkPlans = async () => { + try { + await loadIntegrationStates(apiBase); + } catch { + // Fall through: the next preview answers again, and its refusal is the + // message the user can act on. + } + refresh(); + await requestDisableAll(true); + }; + const closeBulkPlans = () => { bulkPreviewGenerationRef.current += 1; bulkPreviewAbortRef.current?.abort(); @@ -562,7 +576,7 @@ export default function IntegrationsOverview({ if (staleActions.length > 0) { refresh(); setBulkPending(false); - setBulkPlans({ actions: staleActions, loading: false, failure: null, stale: true, failures: failed }); + setBulkPlans({ actions: staleActions, loading: false, failure: null, recovery: null, stale: true, failures: failed }); setBulkResult(failed.length === 0 ? null : { tone: "err", text: t("integrations.bulk.partial", { clients: failed.join("; ") }) }); @@ -1004,6 +1018,8 @@ export default function IntegrationsOverview({ planStale={bulkPlans.stale} planLoading={bulkPlans.loading} planFailure={bulkPlans.failure} + previewRecovery={bulkPlans.recovery} + onPreviewRecovery={recoverBulkPlans} onClose={closeBulkPlans} onConfirm={async () => { await disableAll(bulkPlans); }} /> From 4be87bf93d6df6fae8c68009185abbc63f16949c Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 05:52:51 +0900 Subject: [PATCH 3/4] fix(gui): disable confirm while a preview still needs the catalog A refused preview leaves no plan, so the confirm button must not look live while "Load catalog and retry" is the only action that can produce one. Closes #8354 --- gui/src/pages/integrations/ConsequenceDialog.tsx | 7 ++++++- gui/tests/integrations-preview-recovery.test.tsx | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/gui/src/pages/integrations/ConsequenceDialog.tsx b/gui/src/pages/integrations/ConsequenceDialog.tsx index e80891fb02c..80acb01b99b 100644 --- a/gui/src/pages/integrations/ConsequenceDialog.tsx +++ b/gui/src/pages/integrations/ConsequenceDialog.tsx @@ -186,7 +186,12 @@ export default function ConsequenceDialog({ {recovering ? t("integrations.preview.recovering") : t("integrations.preview.loadCatalogRetry")} )} - diff --git a/gui/tests/integrations-preview-recovery.test.tsx b/gui/tests/integrations-preview-recovery.test.tsx index 84cb4b495db..40f8e72696b 100644 --- a/gui/tests/integrations-preview-recovery.test.tsx +++ b/gui/tests/integrations-preview-recovery.test.tsx @@ -217,6 +217,9 @@ test("an unavailable preview keeps the dialog open and retries after an ordinary const retry = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Load catalog and retry") as HTMLButtonElement | undefined; expect(retry).toBeDefined(); expect(dialog!.textContent).toContain("model catalog is not loaded"); + // No plan exists yet, so confirming must not look available. + const refusedConfirm = Array.from(dialog!.querySelectorAll("button")).find(button => (button.textContent ?? "").trim() === "Replace") as HTMLButtonElement; + expect(refusedConfirm.disabled).toBe(true); await act(async () => { retry!.click(); }); await settle(); From de82975b805c9c460d8e8d8b0374a1e5defe604a Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 9 Nov 2026 08:06:31 +0900 Subject: [PATCH 4/4] fix(gui): keep a closed integration dialog closed when its catalog recovery finishes --- .../content/docs/fr/guides/integrations.md | 9 ++-- .../content/docs/ja/guides/integrations.md | 4 +- .../content/docs/ko/guides/integrations.md | 4 +- .../content/docs/ru/guides/integrations.md | 9 ++-- .../content/docs/tr/guides/integrations.md | 9 ++-- .../content/docs/zh-cn/guides/integrations.md | 4 +- .../content/docs/zh-tw/guides/integrations.md | 7 +-- .../pages/integrations/AsideProfilesPage.tsx | 3 ++ .../integrations/FileIntegrationPage.tsx | 3 ++ .../integrations/IntegrationsOverview.tsx | 6 +++ .../integrations-preview-recovery.test.tsx | 44 +++++++++++++++++++ 11 files changed, 84 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/integrations.md b/docs-site/src/content/docs/fr/guides/integrations.md index f909a13a450..cf0dedd48f1 100644 --- a/docs-site/src/content/docs/fr/guides/integrations.md +++ b/docs-site/src/content/docs/fr/guides/integrations.md @@ -150,11 +150,14 @@ synchronisation, même si le document géré ne change pas. Si le fichier change après votre examen, l'écriture est refusée car le plan est devenu obsolète. La boîte de dialogue remplace l'ancien plan par le nouveau et vous demande de confirmer à nouveau ; elle ne relance jamais -l'écriture automatiquement. Si l'aperçu est temporairement indisponible, rechargez normalement la page et -recommencez l'action. +l'écriture automatiquement. Si l'aperçu indique qu'aucun catalogue de modèles n'est encore chargé, la boîte de +dialogue reste ouverte et propose **Charger le catalogue et réessayer** : elle relit la liste des intégrations, +puis redemande le plan. Aside utilise le même flux d'aperçu et de confirmation pour un seul profil sélectionné à la fois. **Synchroniser -tous les profils** reste une action groupée distincte et n'est pas liée à un aperçu combiné unique. +tous les profils** reste une action groupée distincte et n'est pas liée à un aperçu combiné unique. Un plan décrit le fichier d'un seul profil : la carte +de la Vue d'ensemble d'une intégration Aside en conflit propose donc **Choisir un profil**, et le bloc se +remplace depuis la page de ce profil. ## À quoi s'attendre, en toute transparence diff --git a/docs-site/src/content/docs/ja/guides/integrations.md b/docs-site/src/content/docs/ja/guides/integrations.md index e34ff7db555..22dab73354b 100644 --- a/docs-site/src/content/docs/ja/guides/integrations.md +++ b/docs-site/src/content/docs/ja/guides/integrations.md @@ -100,9 +100,9 @@ Apply、Replace、Disable、Restore はプレビューから始まります。 計画に変更がない場合、管理対象のクライアント文書は要求した状態にすでになっています。選択された Aside プロファイルでは、文書に変更がなくても、確定によって同期の設定が保存される場合があります。 -確認後にファイルが変更されると、古い計画として書き込みが拒否されます。ダイアログは更新された計画に置き換えて再確認を求め、自動再試行はしません。プレビューが一時的に使えない場合は、通常どおりページを再読み込みして操作をやり直してください。 +確認後にファイルが変更されると、古い計画として書き込みが拒否されます。ダイアログは更新された計画に置き換えて再確認を求め、自動再試行はしません。プレビューがモデルカタログ未読み込みを報告した場合、ダイアログは開いたままで **カタログを読み込んで再試行** を表示します。統合一覧を読み直してから計画を再度取得します。 -Aside も選択したプロファイル 1 件ずつ同じプレビューと確認の手順を使います。**Sync all profiles** は別の一括操作であり、1 つの統合プレビューには結び付きません。 +Aside も選択したプロファイル 1 件ずつ同じプレビューと確認の手順を使います。**Sync all profiles** は別の一括操作であり、1 つの統合プレビューには結び付きません。1 つの計画は 1 プロファイルのファイルだけを表すため、競合している Aside の概要カードには **プロファイルを選択** が表示され、ブロックの置き換えはそのプロファイルのページで行います。 ## 実際に起こること diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index db7efc81b07..9e70b4e255a 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -106,9 +106,9 @@ Apply, Replace, Disable, Restore는 미리 보기로 시작합니다. 대화 상 계획에 변경이 없다면 관리 대상 클라이언트 문서가 이미 요청한 상태라는 뜻입니다. Aside 프로필을 선택했다면 관리 문서가 바뀌지 않아도 확인 시 그 프로필의 동기화 기본 설정을 저장할 수 있습니다. -검토 후 파일이 바뀌면 오래된 계획으로 판단해 쓰기를 거부합니다. 대화 상자가 갱신된 계획을 보여주고 다시 확인을 요청하며, 자동으로 쓰기를 재시도하지 않습니다. 미리 보기를 일시적으로 사용할 수 없다면 페이지를 일반적으로 새로고침하고 작업을 다시 시작하세요. +검토 후 파일이 바뀌면 오래된 계획으로 판단해 쓰기를 거부합니다. 대화 상자가 갱신된 계획을 보여주고 다시 확인을 요청하며, 자동으로 쓰기를 재시도하지 않습니다. 미리 보기가 모델 카탈로그가 아직 로드되지 않았다고 알리면 대화 상자는 열린 채로 **카탈로그 로드 후 다시 시도**를 제공합니다. 통합 목록을 다시 읽은 뒤 계획을 다시 요청합니다. -Aside는 선택한 프로필 하나에 같은 미리 보기와 확인 흐름을 사용합니다. **Sync all profiles**는 별도 일괄 작업이며 하나의 통합 미리 보기에 묶이지 않습니다. +Aside는 선택한 프로필 하나에 같은 미리 보기와 확인 흐름을 사용합니다. **Sync all profiles**는 별도 일괄 작업이며 하나의 통합 미리 보기에 묶이지 않습니다. 하나의 계획은 한 프로필의 파일만 설명하므로, 충돌 상태인 Aside 개요 카드는 **프로필 선택**을 제공하고 블록 교체는 해당 프로필 페이지에서 합니다. ## 실제로 예상해야 할 동작 diff --git a/docs-site/src/content/docs/ru/guides/integrations.md b/docs-site/src/content/docs/ru/guides/integrations.md index 6afaa08e162..f55330c7478 100644 --- a/docs-site/src/content/docs/ru/guides/integrations.md +++ b/docs-site/src/content/docs/ru/guides/integrations.md @@ -272,12 +272,15 @@ Apply, Replace, Disable и Restore теперь начинаются с пред Если файл изменится после просмотра, запись будет отклонена как устаревшая. Диалог заменит старый план новым и попросит подтвердить ещё раз; автоматического -повтора записи не будет. Если предпросмотр временно недоступен, обычным способом -обновите страницу и начните действие заново. +повтора записи не будет. Если предпросмотр сообщает, что каталог моделей ещё +не загружен, диалог остаётся открытым и предлагает **Загрузить каталог и повторить**: +он заново читает список интеграций и снова запрашивает план. Для отдельного профиля Aside применяется тот же сценарий предпросмотра и подтверждения. **Sync all profiles** остаётся отдельным массовым действием -без одного общего предпросмотра. +без одного общего предпросмотра. План описывает файл одного профиля, поэтому карточка +обзора для Aside в состоянии конфликта предлагает **Выбрать профиль**, а блок +заменяется на странице этого профиля. ## Чего ожидать на практике diff --git a/docs-site/src/content/docs/tr/guides/integrations.md b/docs-site/src/content/docs/tr/guides/integrations.md index 460e359b560..ba9d2b916c7 100644 --- a/docs-site/src/content/docs/tr/guides/integrations.md +++ b/docs-site/src/content/docs/tr/guides/integrations.md @@ -181,11 +181,14 @@ bir Aside profilinde onaylama, yönetilen belge değişmese bile profilin eşitl Dosya siz inceledikten sonra değişirse yazma işlemi eski plan nedeniyle reddedilir. İletişim kutusu eski planı güncel planla değiştirir ve yeniden açıkça onaylamanızı ister; yazma işlemini hiçbir zaman -otomatik olarak yeniden denemez. Önizleme geçici olarak kullanılamıyorsa sayfayı normal şekilde -yenileyin ve işlemi yeniden başlatın. +otomatik olarak yeniden denemez. Önizleme henüz model kataloğu yüklenmediğini bildirirse +iletişim kutusu açık kalır ve **Kataloğu yükle ve yeniden dene** seçeneğini sunar: entegrasyon listesini +yeniden okur ve planı tekrar ister. Aside, aynı önizleme ve onay akışını bir defada seçili tek bir profil için kullanır. **Tüm profilleri -eşitle** ayrı bir toplu işlem olarak kalır ve tek bir birleşik önizlemeye bağlı değildir. +eşitle** ayrı bir toplu işlem olarak kalır ve tek bir birleşik önizlemeye bağlı değildir. Bir plan yalnızca bir profilin dosyasını +anlatır; bu yüzden çakışmadaki Aside genel bakış kartı **Profil seç** sunar ve blok o profilin +sayfasında değiştirilir. ## Dürüstçe ne beklenmeli? diff --git a/docs-site/src/content/docs/zh-cn/guides/integrations.md b/docs-site/src/content/docs/zh-cn/guides/integrations.md index 6b8c37a9f81..46d0cfd5cbb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/integrations.md +++ b/docs-site/src/content/docs/zh-cn/guides/integrations.md @@ -100,9 +100,9 @@ Apply、Replace、Disable 和 Restore 都先显示预览。对话框准确列出 如果计划显示没有变更,说明托管客户端文档已处于请求的状态。对于选中的 Aside 配置文件,即使托管文档不变,确认操作仍可能保存该配置文件的同步偏好。 -如果检查后文件发生变化,写入会因计划过期而被拒绝。对话框会用更新后的计划替换旧计划,并要求再次确认;绝不会自动重试写入。如果预览暂时不可用,请正常重新加载页面,再发起操作。 +如果检查后文件发生变化,写入会因计划过期而被拒绝。对话框会用更新后的计划替换旧计划,并要求再次确认;绝不会自动重试写入。如果预览提示尚未加载模型目录,对话框会保持打开并提供 **加载目录并重试**:它会重新读取集成列表,然后再次请求计划。 -Aside 对单个选中配置文件采用相同的预览和确认流程。**Sync all profiles** 仍是单独的批量操作,不与一份合并预览绑定。 +Aside 对单个选中配置文件采用相同的预览和确认流程。**Sync all profiles** 仍是单独的批量操作,不与一份合并预览绑定。一份计划只描述一个配置文件的文件,因此处于冲突状态的 Aside 概览卡片会提供 **选择配置文件**,请在该配置文件页面替换配置块。 ## 实际效果与限制 diff --git a/docs-site/src/content/docs/zh-tw/guides/integrations.md b/docs-site/src/content/docs/zh-tw/guides/integrations.md index e4b070287aa..30eb97ce21d 100644 --- a/docs-site/src/content/docs/zh-tw/guides/integrations.md +++ b/docs-site/src/content/docs/zh-tw/guides/integrations.md @@ -104,11 +104,12 @@ Hermes 的會話標識升級是上述衝突規則的特例:既有受管設定 即使受管理的文件沒有變更,確認後仍可能儲存該設定檔的同步偏好。 如果檔案在你檢視後又有變更,寫入會因計畫過期而被拒絕。對話框會用更新後的計畫取代舊 -計畫,並要求你再次明確確認;它絕不會自動重試寫入。如果預覽暫時無法使用,請正常重新 -載入頁面,再重新開始該操作。 +計畫,並要求你再次明確確認;它絕不會自動重試寫入。如果預覽表示尚未載入模型目錄,對話框會保持開啟並 +提供 **載入目錄並重試**:它會重新讀取整合清單,再重新要求計畫。 Aside 會對一次選取的單一設定檔使用相同的預覽與確認流程。**同步所有設定檔**仍是獨立的 -批次操作,不會綁定到單一合併預覽。 +批次操作,不會綁定到單一合併預覽。一份計畫只描述一個設定檔的檔案, +因此處於衝突狀態的 Aside 概覽卡片會提供 **選擇設定檔**,請在該設定檔頁面取代設定區塊。 ## 誠實的預期 diff --git a/gui/src/pages/integrations/AsideProfilesPage.tsx b/gui/src/pages/integrations/AsideProfilesPage.tsx index c43da82753a..7a19d039d3f 100644 --- a/gui/src/pages/integrations/AsideProfilesPage.tsx +++ b/gui/src/pages/integrations/AsideProfilesPage.tsx @@ -139,12 +139,15 @@ export default function AsideProfilesPage({ apiBase, active = true }: { apiBase: const recoverPlanned = async () => { const pendingPlan = planned; if (!pendingPlan) return; + // Close and any newer preview move the generation; a stale recovery must not revive it. + const generation = previewGenerationRef.current; try { await loadIntegrationState(apiBase, "aside", undefined, pendingPlan.profile.profileId); } catch { // Fall through: the preview answers again, and a second banner here // would only hide the refusal the user can act on. } + if (generation !== previewGenerationRef.current) return; resource.refresh(); await requestProfileMutation(pendingPlan.profile, pendingPlan.enabled, true); }; diff --git a/gui/src/pages/integrations/FileIntegrationPage.tsx b/gui/src/pages/integrations/FileIntegrationPage.tsx index a489fbc3ee8..fa42fa0866f 100644 --- a/gui/src/pages/integrations/FileIntegrationPage.tsx +++ b/gui/src/pages/integrations/FileIntegrationPage.tsx @@ -271,6 +271,8 @@ function FileIntegrationControls({ const recoverPlannedMutation = async () => { const pendingOperation = plannedMutation; if (!pendingOperation) return; + // Close and any newer preview move the generation; a stale recovery must not revive it. + const generation = previewGenerationRef.current; const defaults = pendingOperation.droidReasoningDefaults; /* * This read IS the recovery: the server retains its export roster from an @@ -284,6 +286,7 @@ function FileIntegrationControls({ // A failed read is not a plan; fall through and let the preview say so // again rather than reporting a second, unrelated error. } + if (generation !== previewGenerationRef.current) return; refresh(); await requestMutation(pendingOperation.operation, defaults, true); }; diff --git a/gui/src/pages/integrations/IntegrationsOverview.tsx b/gui/src/pages/integrations/IntegrationsOverview.tsx index 6a561b76e54..88ac713e035 100644 --- a/gui/src/pages/integrations/IntegrationsOverview.tsx +++ b/gui/src/pages/integrations/IntegrationsOverview.tsx @@ -512,12 +512,15 @@ export default function IntegrationsOverview({ }; const recoverBulkPlans = async () => { + // Close and any newer bulk preview move the generation; a stale recovery must not revive it. + const generation = bulkPreviewGenerationRef.current; try { await loadIntegrationStates(apiBase); } catch { // Fall through: the next preview answers again, and its refusal is the // message the user can act on. } + if (generation !== bulkPreviewGenerationRef.current) return; refresh(); await requestDisableAll(true); }; @@ -722,12 +725,15 @@ export default function IntegrationsOverview({ const recoverPlannedCard = async () => { const planned = plannedCard; if (!planned) return; + // Close and any newer preview move the generation; a stale recovery must not revive it. + const generation = cardPreviewGenerationRef.current; try { await loadIntegrationStates(apiBase); } catch { // The preview will answer (or refuse) again; a second error banner here // would only obscure the one the user can act on. } + if (generation !== cardPreviewGenerationRef.current) return; refresh(); await requestFilePlan(planned.row, planned.operation, true); }; diff --git a/gui/tests/integrations-preview-recovery.test.tsx b/gui/tests/integrations-preview-recovery.test.tsx index 40f8e72696b..dc979748967 100644 --- a/gui/tests/integrations-preview-recovery.test.tsx +++ b/gui/tests/integrations-preview-recovery.test.tsx @@ -269,3 +269,47 @@ test("a per-profile Aside overwrite offers the same catalog retry and keeps its enabled: true, overwriteConflict: true, operation: "overwrite", planFingerprint: plan("aside", "overwrite", { profileId: 4 }).fingerprint, }); }); + +test("a recovery read still pending when the dialog closes does not reopen it, and the retry waits for the read", async () => { + clients = [fileStatus({ state: "conflict", reason: "unowned-key" })]; + let previews = 0; + preview = body => { + previews += 1; + return previews === 1 + ? json({ error: "no model roster is cached yet", code: "integration_preview_unavailable" }, 409) + : json(plan(String(body.clientId), String(body.operation))); + }; + let release: (() => void) | null = null; + let gate: Promise | null = null; + const originalFetch = globalThis.fetch; + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input); + if (gate && (init?.method ?? "GET").toUpperCase() === "GET" && url.endsWith("/api/client-integrations")) await gate; + return originalFetch(input, init); + }) as typeof fetch, + }); + Object.defineProperty(testWindow, "fetch", { configurable: true, value: globalThis.fetch }); + + const { default: IntegrationsOverview } = await import("../src/pages/integrations/IntegrationsOverview"); + await mount(async () => ({ name: "overview", element: })); + await act(async () => { buttonByText("Replace")!.click(); }); + await settle(); + expect(container.querySelector("dialog[open]")).not.toBeNull(); + + gate = new Promise(resolve => { release = resolve; }); + await act(async () => { buttonByText("Load catalog and retry")!.click(); }); + await settle(); + // The retry may not ask again before the roster-retaining read has answered. + expect(previews).toBe(1); + await act(async () => { buttonByText("Close")!.click(); }); + await settle(); + expect(container.querySelector("dialog[open]")).toBeNull(); + await act(async () => { release!(); gate = null; }); + await settle(80); + // A dismissed action stays dismissed: no second preview, no reopened dialog. + expect(previews).toBe(1); + expect(container.querySelector("dialog[open]")).toBeNull(); +}); +