diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 1942d1da2..2b266ff92 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; -import { type AxcutAsset, ensureDocument } from "@/lib/ai-edition/schema"; +import type { AxcutAsset } from "@/lib/ai-edition/schema"; +import { applyAgentDocumentIfCurrent } from "@/lib/ai-edition/store/agentDocumentApply"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useAssetTranscriptions, @@ -869,15 +870,13 @@ function ChatStripPanel() { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }); - // Apply a document returned by the agent (tool batch or undo). setDocument - // pushes the previous doc to the local undo stack (Cmd+Z also works), then - // saveDocument persists it to disk. - const applyAgentDocument = useCallback(async (doc: unknown) => { - const parsed = ensureDocument(doc); - const store = useProjectStore.getState(); - store.setDocument(parsed); - await store.saveDocument(parsed); - }, []); + // Apply a document returned by the agent (tool batch or rewind). Agent turns + // supply their starting revision so a concurrent manual edit wins; an explicit + // rewind omits it because replacing the live document is the confirmed action. + const applyAgentDocument = useCallback( + (doc: unknown, expectedRevision?: number) => applyAgentDocumentIfCurrent(doc, expectedRevision), + [], + ); const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); @@ -928,7 +927,9 @@ function ChatStripPanel() { thinkingRunSessionRef.current = sessionId; // Send the current document snapshot so the agent can run edit tools // against it (P1). Falls back to text-only chat when no doc is open. - const documentSnapshot = useProjectStore.getState().document ?? undefined; + const snapshot = useProjectStore.getState(); + const documentSnapshot = snapshot.document ?? undefined; + const documentRevision = snapshot.revision; const result = await nativeBridgeClient.aiEdition.chatRun( projectId, sessionId, @@ -939,7 +940,10 @@ function ChatStripPanel() { if (result.success && assistant) { if (result.document) { try { - await applyAgentDocument(result.document); + const applyResult = await applyAgentDocument(result.document, documentRevision); + if (applyResult === "conflict") { + toast.warning(t("chat.agentEditConflict")); + } } catch (err) { toast.error(t("chat.applyEditsFailed"), { description: err instanceof Error ? err.message : String(err), diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index fb3d6875a..580839894 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "تعذّر اختيار النموذج", "providerSettings": "إعدادات المزوّد…", "applyEditsFailed": "تعذّر تطبيق تعديلات الوكيل", + "agentEditConflict": "لم تُطبَّق تعديلات الوكيل لأن المشروع تغيّر أثناء عمله.", "chatFailed": "فشلت المحادثة", "rewindFailed": "فشلت إعادة الضبط", "rewoundSuccess": "تمت إعادة الضبط إلى بداية تلك الرسالة", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index fe7fe12f9..f3e456c60 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Could not select model", "providerSettings": "Provider settings…", "applyEditsFailed": "Could not apply the agent's edits", + "agentEditConflict": "Agent edits were not applied because the project changed while it was working.", "chatFailed": "Chat failed", "rewindFailed": "Rewind failed", "rewoundSuccess": "Rewound to the start of that message", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index efc550fa7..8f318607f 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "No se pudo seleccionar el modelo", "providerSettings": "Configuración del proveedor…", "applyEditsFailed": "No se pudieron aplicar las ediciones del agente", + "agentEditConflict": "Las ediciones del agente no se aplicaron porque el proyecto cambió mientras trabajaba.", "chatFailed": "Error en el chat", "rewindFailed": "Error al rebobinar", "rewoundSuccess": "Rebobinado al inicio de ese mensaje", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 152b139d9..d856bed1b 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Impossible de sélectionner le modèle", "providerSettings": "Réglages du fournisseur…", "applyEditsFailed": "Impossible d'appliquer les modifications de l'agent", + "agentEditConflict": "Les modifications de l'agent n'ont pas été appliquées, car le projet a changé pendant son travail.", "chatFailed": "Échec du chat", "rewindFailed": "Échec du retour en arrière", "rewoundSuccess": "Retour au début de ce message effectué", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 8462d90a4..cac82dd73 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Impossibile selezionare il modello", "providerSettings": "Impostazioni provider…", "applyEditsFailed": "Impossibile applicare le modifiche dell'agente", + "agentEditConflict": "Le modifiche dell'agente non sono state applicate perché il progetto è cambiato durante l'elaborazione.", "chatFailed": "Chat non riuscita", "rewindFailed": "Riavvolgimento non riuscito", "rewoundSuccess": "Riavvolto all'inizio di quel messaggio", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 0ec709cce..71c7db5e1 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "モデルを選択できませんでした", "providerSettings": "プロバイダー設定…", "applyEditsFailed": "エージェントの編集を適用できませんでした", + "agentEditConflict": "エージェントの処理中にプロジェクトが変更されたため、編集は適用されませんでした。", "chatFailed": "チャットに失敗しました", "rewindFailed": "巻き戻しに失敗しました", "rewoundSuccess": "そのメッセージの先頭まで巻き戻しました", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index f6abeea22..3713c0161 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "모델을 선택할 수 없습니다", "providerSettings": "제공업체 설정…", "applyEditsFailed": "에이전트의 편집을 적용할 수 없습니다", + "agentEditConflict": "에이전트가 작업하는 동안 프로젝트가 변경되어 편집 내용이 적용되지 않았습니다.", "chatFailed": "채팅 실패", "rewindFailed": "되감기 실패", "rewoundSuccess": "해당 메시지 시작 지점으로 되감았습니다", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 71af170d5..526e773d0 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Não foi possível selecionar o modelo", "providerSettings": "Configurações do provedor…", "applyEditsFailed": "Não foi possível aplicar as edições do agente", + "agentEditConflict": "As edições do agente não foram aplicadas porque o projeto mudou durante o processamento.", "chatFailed": "Falha no chat", "rewindFailed": "Falha ao rebobinar", "rewoundSuccess": "Rebobinado até o início dessa mensagem", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 451c4d062..133f860c2 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Не удалось выбрать модель", "providerSettings": "Настройки провайдера…", "applyEditsFailed": "Не удалось применить правки агента", + "agentEditConflict": "Правки агента не применены, потому что проект изменился во время его работы.", "chatFailed": "Ошибка чата", "rewindFailed": "Ошибка отката", "rewoundSuccess": "Откат к началу этого сообщения выполнен", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index becba505e..c9677158a 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Model seçilemedi", "providerSettings": "Sağlayıcı ayarları…", "applyEditsFailed": "Aracının düzenlemeleri uygulanamadı", + "agentEditConflict": "Aracı çalışırken proje değiştiği için düzenlemeleri uygulanmadı.", "chatFailed": "Sohbet başarısız oldu", "rewindFailed": "Geri sarma başarısız oldu", "rewoundSuccess": "O mesajın başına geri sarıldı", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 8fa562492..7b7187cd3 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "Không thể chọn mô hình", "providerSettings": "Cài đặt nhà cung cấp…", "applyEditsFailed": "Không thể áp dụng chỉnh sửa của tác nhân", + "agentEditConflict": "Các chỉnh sửa của tác nhân không được áp dụng vì dự án đã thay đổi trong lúc xử lý.", "chatFailed": "Trò chuyện thất bại", "rewindFailed": "Tua lại thất bại", "rewoundSuccess": "Đã tua lại đến đầu tin nhắn đó", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index dd629f57f..1ee47890d 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "无法选择模型", "providerSettings": "提供方设置…", "applyEditsFailed": "无法应用代理的编辑", + "agentEditConflict": "代理工作期间项目已更改,因此未应用代理的编辑。", "chatFailed": "聊天失败", "rewindFailed": "回退失败", "rewoundSuccess": "已回退到该消息的开头", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 865096d0f..75259b9b3 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -235,6 +235,7 @@ "selectModelFailed": "無法選擇模型", "providerSettings": "提供者設定…", "applyEditsFailed": "無法套用代理的編輯", + "agentEditConflict": "代理執行期間專案已變更,因此未套用代理的編輯。", "chatFailed": "聊天失敗", "rewindFailed": "倒轉失敗", "rewoundSuccess": "已倒轉至該訊息的開頭", diff --git a/src/lib/ai-edition/store/agentDocumentApply.test.ts b/src/lib/ai-edition/store/agentDocumentApply.test.ts new file mode 100644 index 000000000..fd38ad0ee --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentApply.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyDocument } from "../schema"; +import { applyAgentDocumentIfCurrent } from "./agentDocumentApply"; +import { useProjectStore } from "./projectStore"; + +const saveMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: { save: saveMock }, + }, +})); + +describe("applyAgentDocumentIfCurrent", () => { + beforeEach(() => { + useProjectStore.getState().clear(); + saveMock.mockReset(); + }); + + it("applies an agent result when the document revision is unchanged", async () => { + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + const agentResult = { + ...before, + project: { ...before.project, title: "Agent edit" }, + }; + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + await expect(applyAgentDocumentIfCurrent(agentResult, 4)).resolves.toBe("applied"); + + expect(saveMock).toHaveBeenCalledOnce(); + expect(useProjectStore.getState().document?.project.title).toBe("Agent edit"); + }); + + it("preserves a manual edit made after the agent snapshot", async () => { + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + const agentResult = { + ...before, + project: { ...before.project, title: "Agent edit" }, + }; + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + useProjectStore.getState().setDocument({ + ...before, + project: { ...before.project, title: "Manual edit" }, + }); + + await expect(applyAgentDocumentIfCurrent(agentResult, 4)).resolves.toBe("conflict"); + + expect(saveMock).not.toHaveBeenCalled(); + expect(useProjectStore.getState().document?.project.title).toBe("Manual edit"); + }); + + it("allows an explicit rewind to replace the current revision", async () => { + const current = createEmptyDocument({ projectId: "project_1", title: "Current" }); + const checkpoint = { + ...current, + project: { ...current.project, title: "Checkpoint" }, + }; + useProjectStore.setState({ projectId: "project_1", document: current, revision: 9 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + await expect(applyAgentDocumentIfCurrent(checkpoint)).resolves.toBe("applied"); + + expect(useProjectStore.getState().document?.project.title).toBe("Checkpoint"); + }); +}); diff --git a/src/lib/ai-edition/store/agentDocumentApply.ts b/src/lib/ai-edition/store/agentDocumentApply.ts new file mode 100644 index 000000000..c2a0a1e5e --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentApply.ts @@ -0,0 +1,26 @@ +import { ensureDocument } from "../schema"; +import { useProjectStore } from "./projectStore"; + +export type AgentDocumentApplyResult = "applied" | "conflict"; + +/** + * Apply a full document returned by the agent only if the live editor is still + * on the revision used to start that agent turn. + * + * `expectedRevision` is omitted for explicit rewind operations, where replacing + * the current document is the action the user just confirmed. + */ +export async function applyAgentDocumentIfCurrent( + document: unknown, + expectedRevision?: number, +): Promise { + const store = useProjectStore.getState(); + if (expectedRevision !== undefined && store.revision !== expectedRevision) { + return "conflict"; + } + + const parsed = ensureDocument(document); + store.setDocument(parsed); + await store.saveDocument(parsed); + return "applied"; +}