diff --git a/app/src/components/flows/WorkflowCopilotPanel.test.tsx b/app/src/components/flows/WorkflowCopilotPanel.test.tsx index 1e896b19b..5c589fb29 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.test.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.test.tsx @@ -244,6 +244,107 @@ describe('WorkflowCopilotPanel', () => { expect(hookState.clearProposal).not.toHaveBeenCalled(); }); + // PR1 — "Save & enable": a second button next to "Accept & save" that asks + // the host to save AND arm the flow in one click, mirroring the main-chat + // `WorkflowProposalCard`'s create+arm parity. + describe('Save & enable (PR1)', () => { + it('calls onAccept with { enable: true } and clears the proposal once it resolves', async () => { + const onAccept = vi.fn().mockResolvedValue(undefined); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + expect(onAccept).toHaveBeenCalledWith(hookState.proposal, { enable: true }); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('shows the enabling label and disables both accept buttons while the host save is in flight', async () => { + let resolveSave!: () => void; + const savePromise = new Promise(resolve => { + resolveSave = resolve; + }); + const onAccept = vi.fn().mockReturnValue(savePromise); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).toHaveTextContent( + 'flows.copilot.enabling' + ) + ); + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).toBeDisabled(); + // The plain "Accept & save" button must also be disabled while the + // enable-flavored save is in flight — the two must not race. + expect(screen.getByTestId('workflow-copilot-accept')).toBeDisabled(); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + + resolveSave(); + await waitFor(() => expect(hookState.clearProposal).toHaveBeenCalledTimes(1)); + }); + + it('leaves the proposal visible and shows an enable-error message when the host save/enable rejects', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('enable failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept-and-enable')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + // The button re-enables once the rejected save settles, the proposal + // was never cleared (stays up for retry), and the dedicated enable-error + // message appears. + await waitFor(() => + expect(screen.getByTestId('workflow-copilot-accept-and-enable')).not.toBeDisabled() + ); + expect(hookState.clearProposal).not.toHaveBeenCalled(); + expect(screen.getByTestId('workflow-copilot-enable-error')).toHaveTextContent( + 'flows.copilot.enableError' + ); + }); + + it('does not show the enable-error message for a plain Accept & save failure', async () => { + const onAccept = vi.fn().mockRejectedValue(new Error('save failed')); + hookState.proposal = proposalWith(['a', 'c']); + render( + + ); + + fireEvent.click(screen.getByTestId('workflow-copilot-accept')); + await waitFor(() => expect(onAccept).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.getByTestId('workflow-copilot-accept')).not.toBeDisabled()); + expect(screen.queryByTestId('workflow-copilot-enable-error')).not.toBeInTheDocument(); + }); + }); + it('disables Reject while an Accept save is in flight, so it cannot race the persisted save', async () => { // Regression for the CodeRabbit finding: Reject must not stay clickable // while `onAccept`'s save is still pending, otherwise the user's cancel diff --git a/app/src/components/flows/WorkflowCopilotPanel.tsx b/app/src/components/flows/WorkflowCopilotPanel.tsx index af4a67ea3..e68beeb82 100644 --- a/app/src/components/flows/WorkflowCopilotPanel.tsx +++ b/app/src/components/flows/WorkflowCopilotPanel.tsx @@ -75,8 +75,14 @@ interface Props { * persists it — "accept" is now review + save in one step). May return a * promise the panel awaits to show a saving state; a rejected promise * leaves the proposal visible so the user can retry. + * + * `opts.enable` (PR1 — "Save & enable") requests an immediate follow-up + * arm after the save succeeds, mirroring the main-chat + * `WorkflowProposalCard`'s one-click create+arm. Optional and backward + * compatible — a plain "Accept & save" click omits `opts` entirely, so it + * neither enables nor force-disables an already-enabled existing flow. */ - onAccept: (proposal: WorkflowProposal) => void | Promise; + onAccept: (proposal: WorkflowProposal, opts?: { enable?: boolean }) => void | Promise; /** Reject the pending proposal (host reverts the overlay). */ onReject: () => void; /** Close the panel. */ @@ -168,11 +174,25 @@ export default function WorkflowCopilotPanel({ const fileInputRef = useRef(null); const isComposingTextRef = useRef(false); + // Set only when a "Save & enable" attempt's `onAccept` rejects — surfaced + // as a dedicated inline message (`flows.copilot.enableError`) distinct from + // a plain "Accept & save" failure, which stays silent-but-retryable as + // before (the button re-enabling is signal enough there). Declared early + // (ahead of the proposal-surfacing effect below, which also clears it) so + // both that effect and the accept/reject handlers further down can + // reference it without a temporal-dead-zone ordering issue. + const [enableError, setEnableError] = useState(false); + // Surface each NEW proposal to the host exactly once (enter preview overlay). const lastSurfacedRef = useRef(null); useEffect(() => { if (proposal && proposal !== lastSurfacedRef.current) { lastSurfacedRef.current = proposal; + // A genuinely new proposal object replacing a prior one (e.g. a further + // revise turn) supersedes any stale "Save & enable" failure from the + // earlier proposal — clear it so the new card doesn't inherit an + // unrelated error message. + setEnableError(false); onProposal(proposal); } }, [proposal, onProposal]); @@ -376,39 +396,61 @@ export default function WorkflowCopilotPanel({ // Accept now review-and-saves: `onAccept` (the host's `handleAcceptProposal`) // applies the proposal to the draft AND persists it. Track a local - // `acceptSaving` flag so the button can show a saving state and disable - // re-clicks while that's in flight. If the host's save throws, leave the - // proposal card visible (don't `clearProposal()`) so the user can retry — - // otherwise a failed autosave would silently vanish the only affordance to - // try again from the copilot itself (the header Save button is a fallback, - // but this keeps the copilot's own flow self-contained). - const [acceptSaving, setAcceptSaving] = useState(false); - const accept = useCallback(async () => { - // Self-guard against re-entrance: the JSX `disabled={acceptSaving}` on - // the Accept button prevents a normal double-click, but `acceptSaving` - // only flips after the FIRST call's `setAcceptSaving(true)` commits — a - // second invocation racing ahead of that render (e.g. programmatic - // re-fire) must not start a second concurrent save. - if (!proposal || acceptSaving) return; - setAcceptSaving(true); - log('accept: saving proposal via host onAccept'); - try { - await onAccept(proposal); - log('accept: save succeeded, clearing proposal'); - clearProposal(); - lastSurfacedRef.current = null; - } catch (err) { - log('accept: save failed, leaving proposal visible for retry err=%o', err); - } finally { - setAcceptSaving(false); - } - }, [proposal, acceptSaving, onAccept, clearProposal]); + // `acceptState` union (rather than a plain boolean) so the two accept + // buttons ("Accept & save" / "Save & enable", PR1) can each show their own + // in-flight label while BOTH stay disabled — a save-in-flight click on the + // other button, or Reject, must not race the pending persist. If the + // host's save (or enable) throws, leave the proposal card visible (don't + // `clearProposal()`) so the user can retry — otherwise a failed autosave + // would silently vanish the only affordance to try again from the copilot + // itself (the header Save button is a fallback, but this keeps the + // copilot's own flow self-contained). + const [acceptState, setAcceptState] = useState<'idle' | 'saving' | 'enabling'>('idle'); + const acceptBusy = acceptState !== 'idle'; + const runAccept = useCallback( + async (opts?: { enable?: boolean }) => { + // Self-guard against re-entrance: the JSX `disabled={acceptBusy}` on + // both buttons prevents a normal double-click, but `acceptState` only + // flips after the FIRST call's `setAcceptState(...)` commits — a + // second invocation racing ahead of that render (e.g. programmatic + // re-fire) must not start a second concurrent save. + if (!proposal || acceptBusy) return; + const enable = Boolean(opts?.enable); + setAcceptState(enable ? 'enabling' : 'saving'); + setEnableError(false); + log('accept: saving proposal via host onAccept enable=%s', enable); + try { + // Plain "Accept & save" calls `onAccept` with just the proposal (no + // second argument at all) — matching the pre-PR1 call signature + // exactly — so a host that doesn't care about `opts` (or a caller + // asserting on `onAccept`'s exact arguments) sees no behavioral + // change. Only "Save & enable" adds the `{ enable: true }` opts. + if (enable) { + await onAccept(proposal, opts); + } else { + await onAccept(proposal); + } + log('accept: save succeeded, clearing proposal'); + clearProposal(); + lastSurfacedRef.current = null; + } catch (err) { + log('accept: save (or enable) failed, leaving proposal visible for retry err=%o', err); + if (enable) setEnableError(true); + } finally { + setAcceptState('idle'); + } + }, + [proposal, acceptBusy, onAccept, clearProposal, setEnableError] + ); + const accept = useCallback(() => runAccept(), [runAccept]); + const acceptAndEnable = useCallback(() => runAccept({ enable: true }), [runAccept]); const reject = useCallback(() => { onReject(); clearProposal(); lastSurfacedRef.current = null; - }, [onReject, clearProposal]); + setEnableError(false); + }, [onReject, clearProposal, setEnableError]); const diff = proposal ? diffGraphs(graph, proposal.graph as WorkflowGraph) : null; @@ -492,26 +534,46 @@ export default function WorkflowCopilotPanel({ )} -
+
+
+ {acceptState === 'idle' && enableError && ( +

+ {t('flows.copilot.enableError')} +

+ )}
)} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 58cefa507..145b79cbf 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4087,7 +4087,10 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'هذا الاقتراح لا يغيّر أي عقدة.', 'flows.copilot.accept': 'تطبيق على المسودة', 'flows.copilot.acceptAndSave': 'قبول وحفظ', + 'flows.copilot.saveAndEnable': 'حفظ وتفعيل', 'flows.copilot.saving': 'جارٍ الحفظ…', + 'flows.copilot.enabling': 'جارٍ التفعيل…', + 'flows.copilot.enableError': 'تم الحفظ، لكن تعذّر تفعيل سير العمل. حاول تفعيله من القائمة.', 'flows.copilot.reject': 'تجاهل', 'flows.copilot.previewHint': 'جارٍ مراجعة مسودة مقترحة: لم يُحفظ شيء بعد.', 'flows.copilot.repairDisplay': 'فشل تشغيل؛ راجعه واقترح إصلاحًا.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 330052021..62f87ff14 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4187,7 +4187,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'এই প্রস্তাব কোনো নোড পরিবর্তন করে না।', 'flows.copilot.accept': 'খসড়ায় প্রয়োগ করুন', 'flows.copilot.acceptAndSave': 'গ্রহণ ও সংরক্ষণ করুন', + 'flows.copilot.saveAndEnable': 'সংরক্ষণ ও সক্রিয় করুন', 'flows.copilot.saving': 'সংরক্ষণ করা হচ্ছে…', + 'flows.copilot.enabling': 'সক্রিয় করা হচ্ছে…', + 'flows.copilot.enableError': + 'সংরক্ষিত হয়েছে, কিন্তু ওয়ার্কফ্লো সক্রিয় করা যায়নি। তালিকা থেকে এটি চালু করার চেষ্টা করুন।', 'flows.copilot.reject': 'বাতিল করুন', 'flows.copilot.previewHint': 'একটি প্রস্তাবিত খসড়া পর্যালোচনা হচ্ছে: এখনও কিছু সংরক্ষণ করা হয়নি।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5b83faec0..630b63ddf 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4306,7 +4306,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Dieser Vorschlag ändert keine Knoten.', 'flows.copilot.accept': 'Auf Entwurf anwenden', 'flows.copilot.acceptAndSave': 'Übernehmen & speichern', + 'flows.copilot.saveAndEnable': 'Speichern & aktivieren', 'flows.copilot.saving': 'Wird gespeichert…', + 'flows.copilot.enabling': 'Wird aktiviert…', + 'flows.copilot.enableError': + 'Workflow gespeichert, konnte aber nicht aktiviert werden. Versuchen Sie es erneut oder aktivieren Sie ihn auf der Workflows-Seite.', 'flows.copilot.reject': 'Verwerfen', 'flows.copilot.previewHint': 'Ein vorgeschlagener Entwurf wird geprüft: es wurde noch nichts gespeichert.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 3368ea52c..036908c71 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4880,7 +4880,11 @@ const en: TranslationMap = { 'flows.copilot.noChanges': 'No node changes in this proposal.', 'flows.copilot.accept': 'Apply to draft', 'flows.copilot.acceptAndSave': 'Accept & save', + 'flows.copilot.saveAndEnable': 'Save & enable', 'flows.copilot.saving': 'Saving…', + 'flows.copilot.enabling': 'Enabling…', + 'flows.copilot.enableError': + 'Saved, but could not enable the workflow. Try toggling it on from the list.', 'flows.copilot.reject': 'Dismiss', 'flows.copilot.previewHint': 'Reviewing a proposed draft: nothing is saved yet.', 'flows.copilot.repairDisplay': 'A run failed. Please review it and propose a fix.', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 870fc00a0..7254c56c3 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4259,7 +4259,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Esta propuesta no cambia ningún nodo.', 'flows.copilot.accept': 'Aplicar al borrador', 'flows.copilot.acceptAndSave': 'Aceptar y guardar', + 'flows.copilot.saveAndEnable': 'Guardar y activar', 'flows.copilot.saving': 'Guardando…', + 'flows.copilot.enabling': 'Activando…', + 'flows.copilot.enableError': + 'Guardado, pero no se pudo activar el flujo de trabajo. Actívalo desde la lista.', 'flows.copilot.reject': 'Descartar', 'flows.copilot.previewHint': 'Revisando un borrador propuesto: aún no se ha guardado nada.', 'flows.copilot.repairDisplay': 'Falló una ejecución; revísala y propón una solución.', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 2a4b49ee0..41e7d09a0 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4287,7 +4287,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Cette proposition ne modifie aucun nœud.', 'flows.copilot.accept': 'Appliquer au brouillon', 'flows.copilot.acceptAndSave': 'Accepter et enregistrer', + 'flows.copilot.saveAndEnable': 'Enregistrer et activer', 'flows.copilot.saving': 'Enregistrement…', + 'flows.copilot.enabling': 'Activation…', + 'flows.copilot.enableError': + 'Enregistré, mais impossible d’activer le workflow. Essayez de l’activer depuis la liste.', 'flows.copilot.reject': 'Ignorer', 'flows.copilot.previewHint': 'Examen d’un brouillon proposé: rien n’est encore enregistré.', 'flows.copilot.repairDisplay': 'Une exécution a échoué ; examinez-la et proposez une correction.', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index be21c6c87..79138ea39 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4185,7 +4185,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'यह प्रस्ताव किसी नोड को नहीं बदलता।', 'flows.copilot.accept': 'ड्राफ़्ट पर लागू करें', 'flows.copilot.acceptAndSave': 'स्वीकार करें और सहेजें', + 'flows.copilot.saveAndEnable': 'सहेजें और सक्रिय करें', 'flows.copilot.saving': 'सहेजा जा रहा है…', + 'flows.copilot.enabling': 'सक्रिय किया जा रहा है…', + 'flows.copilot.enableError': + 'सहेज लिया गया है, लेकिन वर्कफ़्लो सक्रिय नहीं हो सका। सूची से इसे चालू करने का प्रयास करें।', 'flows.copilot.reject': 'रद्द करें', 'flows.copilot.previewHint': 'एक प्रस्तावित ड्राफ़्ट की समीक्षा हो रही है: अभी कुछ सहेजा नहीं गया।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 1a4a83723..46b4fb742 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4202,7 +4202,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Usulan ini tidak mengubah simpul apa pun.', 'flows.copilot.accept': 'Terapkan ke draf', 'flows.copilot.acceptAndSave': 'Terima & simpan', + 'flows.copilot.saveAndEnable': 'Simpan & aktifkan', 'flows.copilot.saving': 'Menyimpan…', + 'flows.copilot.enabling': 'Mengaktifkan…', + 'flows.copilot.enableError': + 'Tersimpan, tetapi alur kerja tidak dapat diaktifkan. Coba aktifkan dari daftar.', 'flows.copilot.reject': 'Buang', 'flows.copilot.previewHint': 'Meninjau draf yang diusulkan: belum ada yang disimpan.', 'flows.copilot.repairDisplay': 'Sebuah eksekusi gagal; periksa dan usulkan perbaikan.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index d0e6c1bdf..9a86d5e14 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4256,7 +4256,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Questa proposta non modifica alcun nodo.', 'flows.copilot.accept': 'Applica alla bozza', 'flows.copilot.acceptAndSave': 'Accetta e salva', + 'flows.copilot.saveAndEnable': 'Salva e attiva', 'flows.copilot.saving': 'Salvataggio…', + 'flows.copilot.enabling': 'Attivazione…', + 'flows.copilot.enableError': + 'Workflow salvato, ma non è stato possibile attivarlo. Riprova, oppure attivalo dalla pagina Workflows.', 'flows.copilot.reject': 'Ignora', 'flows.copilot.previewHint': 'Revisione di una bozza proposta: non è stato ancora salvato nulla.', 'flows.copilot.repairDisplay': 'Un’esecuzione è fallita; esaminala e proponi una correzione.', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 315bf4b4d..1620ecf3d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4140,7 +4140,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': '이 제안은 노드를 변경하지 않습니다.', 'flows.copilot.accept': '초안에 적용', 'flows.copilot.acceptAndSave': '수락 및 저장', + 'flows.copilot.saveAndEnable': '저장 및 활성화', 'flows.copilot.saving': '저장 중…', + 'flows.copilot.enabling': '활성화 중…', + 'flows.copilot.enableError': + '저장되었지만 워크플로를 활성화하지 못했습니다. 목록에서 활성화해 보세요.', 'flows.copilot.reject': '버리기', 'flows.copilot.previewHint': '제안된 초안을 검토 중입니다: 아직 저장되지 않았습니다.', 'flows.copilot.repairDisplay': '실행이 실패했습니다. 확인하고 수정을 제안하세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 51838b6e9..4a5dda9f2 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4241,7 +4241,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Ta propozycja nie zmienia żadnego węzła.', 'flows.copilot.accept': 'Zastosuj do wersji roboczej', 'flows.copilot.acceptAndSave': 'Zaakceptuj i zapisz', + 'flows.copilot.saveAndEnable': 'Zapisz i włącz', 'flows.copilot.saving': 'Zapisywanie…', + 'flows.copilot.enabling': 'Włączanie…', + 'flows.copilot.enableError': + 'Zapisano, ale nie udało się włączyć przepływu pracy. Spróbuj włączyć go z listy.', 'flows.copilot.reject': 'Odrzuć', 'flows.copilot.previewHint': 'Przeglądasz proponowaną wersję roboczą: nic nie zostało jeszcze zapisane.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 4faab0c69..712be4fbb 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4248,7 +4248,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Esta proposta não altera nenhum nó.', 'flows.copilot.accept': 'Aplicar ao rascunho', 'flows.copilot.acceptAndSave': 'Aceitar e salvar', + 'flows.copilot.saveAndEnable': 'Salvar e ativar', 'flows.copilot.saving': 'Salvando…', + 'flows.copilot.enabling': 'Ativando…', + 'flows.copilot.enableError': + 'Salvo, mas não foi possível ativar o fluxo de trabalho. Tente ativá-lo pela lista.', 'flows.copilot.reject': 'Descartar', 'flows.copilot.previewHint': 'Revisando um rascunho proposto: nada foi salvo ainda.', 'flows.copilot.repairDisplay': 'Uma execução falhou; analise-a e proponha uma correção.', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 5aa939b2c..5bf1c48d9 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4226,7 +4226,11 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': 'Это предложение не меняет ни одного узла.', 'flows.copilot.accept': 'Применить к черновику', 'flows.copilot.acceptAndSave': 'Принять и сохранить', + 'flows.copilot.saveAndEnable': 'Сохранить и включить', 'flows.copilot.saving': 'Сохранение…', + 'flows.copilot.enabling': 'Включение…', + 'flows.copilot.enableError': + 'Сохранено, но не удалось включить рабочий процесс. Попробуйте включить его из списка.', 'flows.copilot.reject': 'Отклонить', 'flows.copilot.previewHint': 'Просмотр предложенного черновика: пока ничего не сохранено.', 'flows.copilot.repairDisplay': 'Запуск завершился ошибкой; изучите его и предложите исправление.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e86dfac2a..4861f5a83 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -3967,7 +3967,10 @@ const messages: TranslationMap = { 'flows.copilot.noChanges': '此方案未更改任何节点。', 'flows.copilot.accept': '应用到草稿', 'flows.copilot.acceptAndSave': '接受并保存', + 'flows.copilot.saveAndEnable': '保存并启用', 'flows.copilot.saving': '保存中…', + 'flows.copilot.enabling': '启用中…', + 'flows.copilot.enableError': '已保存,但无法启用该工作流。请尝试从列表中启用它。', 'flows.copilot.reject': '放弃', 'flows.copilot.previewHint': '正在查看建议的草稿:尚未保存任何内容。', 'flows.copilot.repairDisplay': '一次运行失败了,请查看并提出修复方案。', diff --git a/app/src/pages/FlowCanvasPage.tsx b/app/src/pages/FlowCanvasPage.tsx index 7f8c793c3..a7f42381f 100644 --- a/app/src/pages/FlowCanvasPage.tsx +++ b/app/src/pages/FlowCanvasPage.tsx @@ -44,7 +44,14 @@ import { workflowGraphToXyflow } from '../lib/flows/graphAdapter'; import { buildPreviewGraph, diffGraphs } from '../lib/flows/graphDiff'; import type { WorkflowGraph } from '../lib/flows/types'; import { useT } from '../lib/i18n/I18nContext'; -import { createFlow, type Flow, getFlow, runFlow, updateFlow } from '../services/api/flowsApi'; +import { + createFlow, + type Flow, + getFlow, + runFlow, + setFlowEnabled, + updateFlow, +} from '../services/api/flowsApi'; import type { WorkflowProposal } from '../store/chatRuntimeSlice'; import type { ToastNotification } from '../types/intelligence'; @@ -480,12 +487,30 @@ function FlowEditor({ // // Declared ahead of `handleAcceptProposal` (below), which calls it directly // to persist an accepted proposal immediately. + // + // Returns `flowId`/`flowEnabled` alongside `remounted` so a caller wanting a + // "Save & enable" follow-up (`handleAcceptProposal`'s `opts.enable`) knows + // exactly which flow id to arm and whether the persisted flow already came + // back enabled — without having to re-derive it from component state, which + // is especially important for the draft-create path: `flowId` (the prop) + // is still `null` in THIS closure even after `createFlow` resolves, since + // the draft only becomes a real flow id via the `navigate(...)` below, not + // a state update this same render can observe. const handleSave = useCallback( async ( next: WorkflowGraph, overrideName?: string, - overrideRequireApproval?: boolean - ): Promise<{ remounted: boolean }> => { + overrideRequireApproval?: boolean, + // When true, a draft-create does NOT navigate to `/flows/:id` itself — + // the caller owns navigation timing. `handleAcceptProposal`'s + // "Save & enable" needs this: it must run `setFlowEnabled` on the + // just-created flow BEFORE the route change unmounts this page, else the + // enable RPC resolves against an unmounted component (its loading/error + // state is lost and the new page shows the flow still disabled). The + // `wasDraft` flag in the return tells the caller navigation is now its + // responsibility. + deferDraftNavigation?: boolean + ): Promise<{ remounted: boolean; flowId: string; flowEnabled: boolean; wasDraft: boolean }> => { // `overrideName` covers the copilot-Accept call site: it calls // `setName(proposal.name)` and `handleSave(...)` in the same handler, // but `name` in THIS closure is still the pre-update value — React @@ -510,11 +535,24 @@ function FlowEditor({ effectiveRequireApproval ); const created = await createFlow(effectiveName, next, effectiveRequireApproval); - log('save: draft persisted as flow id=%s', created.id); - navigate(`/flows/${created.id}`, { replace: true }); + log('save: draft persisted as flow id=%s enabled=%s', created.id, created.enabled); + if (!deferDraftNavigation) { + navigate(`/flows/${created.id}`, { replace: true }); + } // Navigating replaces this whole page (new `flowId` route param), so // "remounted" is moot for a draft-create — no caller branches on it. - return { remounted: false }; + // `flowId`/`flowEnabled` DO matter — a "Save & enable" caller reads + // them to arm the just-created flow (B29 Rule 1 always persists an + // automatic-trigger draft disabled, regardless of the caller's + // intent), and this RPC response is the only place that id/enabled + // pair is available before the route change lands. `wasDraft` lets a + // `deferDraftNavigation` caller know it now owns the navigation. + return { + remounted: false, + flowId: created.id, + flowEnabled: created.enabled, + wasDraft: true, + }; } // Only include `name` / `requireApproval` in the update payload when // they actually diverge from what's already persisted (a manual @@ -565,13 +603,14 @@ function FlowEditor({ setCanvasVersion(v => v + 1); } log( - 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d graphChanged=%s', + 'save: flow id=%s persisted — canvas re-synced from response nodes=%d edges=%d graphChanged=%s enabled=%s', flowId, persisted.nodes.length, persisted.edges.length, - graphChanged + graphChanged, + updated.enabled ); - return { remounted: graphChanged }; + return { remounted: graphChanged, flowId, flowEnabled: updated.enabled, wasDraft: false }; }, [isDraft, flowId, name, requireApproval, navigate] ); @@ -624,9 +663,20 @@ function FlowEditor({ // right after Accept would have done. A failed save is non-fatal: the // proposal stays applied to the (now dirty) draft and the header Save // button remains the manual retry — we never crash or revert the draft. + // + // `opts.enable` (PR1 — "Save & enable") mirrors `WorkflowProposalCard.save()` + // in the main chat surface: after a successful save, explicitly arm the + // flow via `setFlowEnabled`. This is needed because `createFlow` with an + // automatic trigger (schedule/app_event/webhook) ALWAYS persists disabled + // (B29 Rule 1, `flowsApi.ts`) regardless of what the caller passed — Rule 1 + // exists to stop a copilot autosave from silently arming an unattended + // automation, but "Save & enable" is the user's own explicit arming click, + // not a silent autosave, so it must follow up. Plain "Accept & save" (no + // `opts`) must NOT enable and must NOT force-disable an already-enabled + // existing flow — it's simply omitted from the call. const handleAcceptProposal = useCallback( - async (proposal: WorkflowProposal) => { - log('copilot proposal accepted'); + async (proposal: WorkflowProposal, opts?: { enable?: boolean }) => { + log('copilot proposal accepted: enable=%s', Boolean(opts?.enable)); const proposedGraph = proposal.graph as WorkflowGraph; setDraftGraph(proposedGraph); setPreview(null); @@ -670,10 +720,18 @@ function FlowEditor({ // `canvasVersion` bump above) so the ref's imperative handle is stale; // call `handleSave` directly with the known-good proposed graph. try { - const { remounted } = await handleSave( + const { + remounted, + flowId: savedFlowId, + flowEnabled, + wasDraft, + } = await handleSave( proposedGraph, overrideName, - proposal.requireApproval + proposal.requireApproval, + // Defer a draft-create's navigation so a "Save & enable" arms the + // flow BEFORE this page unmounts — see `deferDraftNavigation`. + true ); // The canvas remounted once already (this handler's own bump above) // with `forcedDirty` seeded `true` — correct pre-persist, but that @@ -688,15 +746,56 @@ function FlowEditor({ if (!remounted) { canvasRef.current?.clearForcedDirty(); } - log('copilot proposal accepted: persisted remounted=%s', remounted); + log( + 'copilot proposal accepted: persisted remounted=%s flowId=%s flowEnabled=%s', + remounted, + savedFlowId, + flowEnabled + ); + + // "Save & enable": follow up with an explicit arm, same as + // `WorkflowProposalCard.save()`. Fires unconditionally when + // requested (idempotent if the flow already came back enabled) — + // simpler than special-casing an already-enabled flow, and this is + // still inside the same try/catch so a failure here also leaves the + // proposal visible for retry rather than silently vanishing. + if (opts?.enable) { + log('copilot proposal accepted: enabling flow id=%s', savedFlowId); + try { + await setFlowEnabled(savedFlowId, true); + log('copilot proposal accepted: enable succeeded id=%s', savedFlowId); + } catch (enableErr) { + // The flow IS saved at this point. On a DRAFT we must still + // navigate to the created flow (below) or a retry would create a + // duplicate — so we can't keep the proposal for an in-place retry; + // swallow here and let the user arm it from the flow page (matches + // the "Saved, but could not enable" guidance). On an EXISTING flow + // there's no navigation, so rethrow to keep the proposal visible + // for retry, preserving the pre-existing behavior. + if (!wasDraft) throw enableErr; + log( + 'copilot proposal accepted: enable failed on draft; flow saved-but-disabled id=%s err=%o', + savedFlowId, + enableErr + ); + } + } + + // Draft navigation was deferred so the "Save & enable" arm could run + // first; now that persist + enable have settled, move to the real flow + // route. A non-draft accept stays on its existing `/flows/:id` page. + if (wasDraft) { + navigate(`/flows/${savedFlowId}`, { replace: true }); + } } catch (err) { - log('copilot proposal accepted: save failed err=%o', err); + log('copilot proposal accepted: save/enable failed err=%o', err); // Rethrow: the draft above is already applied unconditionally, so no // data is lost by rethrowing. This lets the caller — the copilot - // panel's own `accept` handler — see the failure and skip - // `clearProposal()`, keeping the proposal card visible for retry - // instead of silently vanishing while nothing was actually saved. - // `acceptSaving` there still resets via its own `finally`. + // panel's own `accept`/`acceptAndEnable` handler — see the failure + // and skip `clearProposal()`, keeping the proposal card visible for + // retry instead of silently vanishing while nothing was actually + // saved (or saved-but-not-enabled). `acceptSaving`/`acceptState` + // there still resets via its own `finally`. throw err; } }, diff --git a/app/src/pages/__tests__/FlowCanvasPage.test.tsx b/app/src/pages/__tests__/FlowCanvasPage.test.tsx index 6cbe4a2ca..28596e9eb 100644 --- a/app/src/pages/__tests__/FlowCanvasPage.test.tsx +++ b/app/src/pages/__tests__/FlowCanvasPage.test.tsx @@ -25,6 +25,7 @@ const createFlow = vi.hoisted(() => vi.fn()); const validateFlow = vi.hoisted(() => vi.fn()); const listFlowConnections = vi.hoisted(() => vi.fn()); const runFlow = vi.hoisted(() => vi.fn()); +const setFlowEnabled = vi.hoisted(() => vi.fn()); vi.mock('../../services/api/flowsApi', () => ({ getFlow, updateFlow, @@ -32,6 +33,7 @@ vi.mock('../../services/api/flowsApi', () => ({ validateFlow, listFlowConnections, runFlow, + setFlowEnabled, })); // Stub the copilot panel: it drives the real chat runtime (redux + socket), @@ -99,10 +101,12 @@ describe('FlowCanvasPage', () => { validateFlow.mockReset(); listFlowConnections.mockReset(); runFlow.mockReset(); + setFlowEnabled.mockReset(); validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); listFlowConnections.mockResolvedValue([]); updateFlow.mockResolvedValue(makeFlow()); createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Daily digest' })); + setFlowEnabled.mockResolvedValue(makeFlow({ enabled: true })); }); it('shows a loading state while the flow is being fetched', () => { @@ -687,6 +691,7 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { createFlow.mockReset(); validateFlow.mockReset(); listFlowConnections.mockReset(); + setFlowEnabled.mockReset(); validateFlow.mockResolvedValue({ valid: true, errors: [], warnings: [] }); listFlowConnections.mockResolvedValue([]); // Accept now persists immediately (review + save in one step, see @@ -695,6 +700,7 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { // by an unmocked (`undefined`-resolving) `updateFlow`/`createFlow`. updateFlow.mockResolvedValue(makeFlow()); createFlow.mockResolvedValue(makeFlow({ id: 'created-id' })); + setFlowEnabled.mockResolvedValue(makeFlow({ enabled: true })); }); function renderEditor(id = 'test-id') { @@ -710,12 +716,20 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { // `handleAcceptProposal` is async (it awaits the persist call) — drive it // through `act(async () => …)` so React flushes every state update the - // resulting save produces before the test asserts on them. - function acceptProposal(proposal: WorkflowProposal = makeProposal()) { + // resulting save produces before the test asserts on them. `opts` mirrors + // the copilot panel's own "Save & enable" call (PR1) — omitted for a plain + // Accept & save, `{ enable: true }` for the enable path. + function acceptProposal( + proposal: WorkflowProposal = makeProposal(), + opts?: { enable?: boolean } + ) { return act(async () => { - await (copilotPanelProps.current?.onAccept as (p: WorkflowProposal) => Promise)( - proposal - ); + await ( + copilotPanelProps.current?.onAccept as ( + p: WorkflowProposal, + opts?: { enable?: boolean } + ) => Promise + )(proposal, opts); }); } @@ -984,6 +998,175 @@ describe('FlowCanvasPage copilot proposal name adoption', () => { await waitFor(() => expect(screen.getByTestId('flow-editor-dirty')).toBeInTheDocument()); await waitFor(() => expect(screen.getByTestId('flow-editor-save')).not.toBeDisabled()); }); + + // PR1 — "Save & enable": `handleAcceptProposal`'s `opts.enable` follow-up. + describe('Save & enable (PR1)', () => { + it('calls setFlowEnabled(flowId, true) after a successful save on an existing flow', async () => { + getFlow.mockResolvedValue(makeFlow({ id: 'test-id', enabled: false })); + updateFlow.mockResolvedValue(makeFlow({ id: 'test-id', enabled: false })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(makeProposal(), { enable: true }); + + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).toHaveBeenCalledWith('test-id', true); + }); + + it('calls setFlowEnabled with the newly-created id on a draft', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + getFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(makeProposal(), { enable: true }); + + expect(createFlow).toHaveBeenCalledTimes(1); + await waitFor(() => expect(setFlowEnabled).toHaveBeenCalledTimes(1)); + expect(setFlowEnabled).toHaveBeenCalledWith('created-id', true); + }); + + it('on a draft "Save & enable", runs enable BEFORE navigating, and swallows an enable failure (flow saved, armed from its own page)', async () => { + createFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + getFlow.mockResolvedValue(makeFlow({ id: 'created-id', name: 'Standup reminder' })); + setFlowEnabled.mockRejectedValue(new Error('enable rpc failed')); + render( + + + } /> + } /> + + + ); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + let caughtErr: unknown; + await act(async () => { + try { + await ( + copilotPanelProps.current?.onAccept as ( + p: WorkflowProposal, + opts?: { enable?: boolean } + ) => Promise + )(makeProposal(), { enable: true }); + } catch (err) { + caughtErr = err; + } + }); + + // On a draft the create succeeds first, so the enable is attempted + // BEFORE the deferred navigation (the whole point of the fix — otherwise + // navigate would unmount this page and the enable RPC would resolve + // against a dead component). And because the flow IS saved, a draft + // enable failure must NOT rethrow: rethrowing would strand the user on + // the draft and a retry would create a DUPLICATE flow. Instead we + // navigate to the real flow and let the user arm it there. (Contrast the + // existing-flow rethrow test above, which keeps the proposal for retry.) + expect(createFlow).toHaveBeenCalledTimes(1); + await waitFor(() => expect(setFlowEnabled).toHaveBeenCalledWith('created-id', true)); + expect(caughtErr).toBeUndefined(); + // Navigation to the real flow happened afterward (its page fetches it). + await waitFor(() => expect(getFlow).toHaveBeenCalledWith('created-id')); + }); + + it('does NOT call setFlowEnabled for a plain Accept & save (no opts)', async () => { + getFlow.mockResolvedValue(makeFlow({ id: 'test-id' })); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + await acceptProposal(); + + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).not.toHaveBeenCalled(); + }); + + it('rethrows an enable failure after a successful save, so the saved flow is not lost and the caller can retry', async () => { + getFlow.mockResolvedValue(makeFlow({ id: 'test-id' })); + updateFlow.mockResolvedValue(makeFlow({ id: 'test-id' })); + setFlowEnabled.mockRejectedValue(new Error('enable rpc failed')); + renderEditor(); + await waitFor(() => expect(screen.getByTestId('flow-canvas')).toBeInTheDocument()); + + let caughtErr: unknown; + await act(async () => { + try { + await ( + copilotPanelProps.current?.onAccept as ( + p: WorkflowProposal, + opts?: { enable?: boolean } + ) => Promise + )(makeProposal(), { enable: true }); + } catch (err) { + caughtErr = err; + } + }); + + // The save itself succeeded — `updateFlow` was called and resolved — + // only the follow-up enable call failed. Rethrowing lets the copilot + // panel's own catch branch skip `clearProposal()`, keeping the card + // visible for retry (matching the plain-save failure contract). + expect(updateFlow).toHaveBeenCalledTimes(1); + expect(setFlowEnabled).toHaveBeenCalledTimes(1); + expect(caughtErr).toBeInstanceOf(Error); + expect((caughtErr as Error).message).toBe('enable rpc failed'); + }); + }); }); describe('asCopilotBuildSeed', () => {