Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ar/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"selectModelFailed": "تعذّر اختيار النموذج",
"providerSettings": "إعدادات المزوّد…",
"applyEditsFailed": "تعذّر تطبيق تعديلات الوكيل",
"agentEditConflict": "لم تُطبَّق تعديلات الوكيل لأن المشروع تغيّر أثناء عمله.",
"chatFailed": "فشلت المحادثة",
"rewindFailed": "فشلت إعادة الضبط",
"rewoundSuccess": "تمت إعادة الضبط إلى بداية تلك الرسالة",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/fr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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é",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/it/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ja-JP/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"selectModelFailed": "モデルを選択できませんでした",
"providerSettings": "プロバイダー設定…",
"applyEditsFailed": "エージェントの編集を適用できませんでした",
"agentEditConflict": "エージェントの処理中にプロジェクトが変更されたため、編集は適用されませんでした。",
"chatFailed": "チャットに失敗しました",
"rewindFailed": "巻き戻しに失敗しました",
"rewoundSuccess": "そのメッセージの先頭まで巻き戻しました",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ko-KR/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"selectModelFailed": "모델을 선택할 수 없습니다",
"providerSettings": "제공업체 설정…",
"applyEditsFailed": "에이전트의 편집을 적용할 수 없습니다",
"agentEditConflict": "에이전트가 작업하는 동안 프로젝트가 변경되어 편집 내용이 적용되지 않았습니다.",
"chatFailed": "채팅 실패",
"rewindFailed": "되감기 실패",
"rewoundSuccess": "해당 메시지 시작 지점으로 되감았습니다",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/pt-BR/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/ru/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"selectModelFailed": "Не удалось выбрать модель",
"providerSettings": "Настройки провайдера…",
"applyEditsFailed": "Не удалось применить правки агента",
"agentEditConflict": "Правки агента не применены, потому что проект изменился во время его работы.",
"chatFailed": "Ошибка чата",
"rewindFailed": "Ошибка отката",
"rewoundSuccess": "Откат к началу этого сообщения выполнен",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/tr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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ı",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/vi/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 đó",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-CN/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"selectModelFailed": "无法选择模型",
"providerSettings": "提供方设置…",
"applyEditsFailed": "无法应用代理的编辑",
"agentEditConflict": "代理工作期间项目已更改,因此未应用代理的编辑。",
"chatFailed": "聊天失败",
"rewindFailed": "回退失败",
"rewoundSuccess": "已回退到该消息的开头",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/locales/zh-TW/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
"selectModelFailed": "無法選擇模型",
"providerSettings": "提供者設定…",
"applyEditsFailed": "無法套用代理的編輯",
"agentEditConflict": "代理執行期間專案已變更,因此未套用代理的編輯。",
"chatFailed": "聊天失敗",
"rewindFailed": "倒轉失敗",
"rewoundSuccess": "已倒轉至該訊息的開頭",
Expand Down
67 changes: 67 additions & 0 deletions src/lib/ai-edition/store/agentDocumentApply.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
26 changes: 26 additions & 0 deletions src/lib/ai-edition/store/agentDocumentApply.ts
Original file line number Diff line number Diff line change
@@ -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<AgentDocumentApplyResult> {
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";
}
Loading