diff --git a/apps/planner/app/components/NoGoAreaLayer.tsx b/apps/planner/app/components/NoGoAreaLayer.tsx
index 2111e7d..b6508ee 100644
--- a/apps/planner/app/components/NoGoAreaLayer.tsx
+++ b/apps/planner/app/components/NoGoAreaLayer.tsx
@@ -47,7 +47,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay
polygon.on("contextmenu", (e) => {
L.DomEvent.preventDefault(e as unknown as Event);
suppressObserverRef.current = true;
- noGoAreas.delete(i, 1);
+ doc.transact(() => noGoAreas.delete(i, 1), "local");
suppressObserverRef.current = false;
syncLayers();
});
@@ -101,7 +101,7 @@ export function NoGoAreaLayer({ noGoAreas, doc, enabled, onToggle }: NoGoAreaLay
yMap.set("points", points);
doc.transact(() => {
noGoAreas.push([yMap]);
- });
+ }, "local");
// Exit draw mode
onToggle();
diff --git a/apps/planner/app/components/NotesPanel.tsx b/apps/planner/app/components/NotesPanel.tsx
index 2c870bf..caaf9bb 100644
--- a/apps/planner/app/components/NotesPanel.tsx
+++ b/apps/planner/app/components/NotesPanel.tsx
@@ -42,7 +42,7 @@ export function NotesPanel({ yjs }: NotesPanelProps) {
yjs.doc.transact(() => {
yjs.notes.delete(0, yjs.notes.length);
yjs.notes.insert(0, newValue);
- });
+ }, "local");
isLocalChange.current = false;
},
[yjs.notes, yjs.doc],
diff --git a/apps/planner/app/components/PlannerMap.tsx b/apps/planner/app/components/PlannerMap.tsx
index 73d8a86..f1986d6 100644
--- a/apps/planner/app/components/PlannerMap.tsx
+++ b/apps/planner/app/components/PlannerMap.tsx
@@ -294,23 +294,26 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const addWaypoint = useCallback(
(lat: number, lng: number) => {
- const yMap = new Y.Map();
- yMap.set("lat", lat);
- yMap.set("lon", lng);
- yjs.waypoints.push([yMap]);
+ yjs.doc.transact(() => {
+ const yMap = new Y.Map();
+ yMap.set("lat", lat);
+ yMap.set("lon", lng);
+ yjs.waypoints.push([yMap]);
+ }, "local");
},
- [yjs.waypoints],
+ [yjs.doc, yjs.waypoints],
);
const insertWaypointAtSegment = useCallback(
(segmentIndex: number, lat: number, lon: number) => {
- const yMap = new Y.Map();
- yMap.set("lat", lat);
- yMap.set("lon", lon);
- // Insert after the segment's start waypoint
- yjs.waypoints.insert(segmentIndex + 1, [yMap]);
+ yjs.doc.transact(() => {
+ const yMap = new Y.Map();
+ yMap.set("lat", lat);
+ yMap.set("lon", lon);
+ yjs.waypoints.insert(segmentIndex + 1, [yMap]);
+ }, "local");
},
- [yjs.waypoints],
+ [yjs.doc, yjs.waypoints],
);
const handleRouteInsert = useCallback(
@@ -329,7 +332,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
yjs.doc.transact(() => {
yMap.set("lat", lat);
yMap.set("lon", lng);
- });
+ }, "local");
}
},
[yjs.waypoints, yjs.doc],
@@ -337,7 +340,9 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
const deleteWaypoint = useCallback(
(index: number) => {
- yjs.waypoints.delete(index, 1);
+ yjs.doc.transact(() => {
+ yjs.waypoints.delete(index, 1);
+ }, "local");
},
[yjs.waypoints],
);
@@ -395,7 +400,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
yMap.set("points", area.points);
yjs.noGoAreas.push([yMap]);
}
- });
+ }, "local");
} catch {
onImportError?.(t("importGpxError"));
}
@@ -449,6 +454,7 @@ export function PlannerMap({ yjs, onRouteRequest, highlightPosition, onImportErr
},
dragstart: () => {
waypointDraggingRef.current = true;
+ yjs.undoManager.stopCapturing();
routeInteractionSuspendedRef.current = true;
},
dragend: (e) => {
diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx
index 0964f79..6cea400 100644
--- a/apps/planner/app/components/SessionView.tsx
+++ b/apps/planner/app/components/SessionView.tsx
@@ -5,6 +5,7 @@ import type { TFunction } from "i18next";
import * as Sentry from "@sentry/react";
import { useYjs, type YjsState } from "~/lib/use-yjs";
import { useRouting, type RouteError } from "~/lib/use-routing";
+import { useUndo, useUndoShortcuts } from "~/lib/use-undo";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
import { SaveToJournalButton } from "~/components/SaveToJournalButton";
@@ -187,6 +188,8 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
useEffect(() => { Sentry.setTag("session_id", sessionId); }, [sessionId]);
const yjs = useYjs(sessionId, initialWaypoints, initialNoGoAreas);
const { computing, routeError, routeStats, requestRoute } = useRouting(yjs);
+ const { canUndo, canRedo, undo, redo } = useUndo(yjs?.undoManager ?? null);
+ useUndoShortcuts(yjs?.undoManager ?? null);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
const { toasts, addToast } = useToasts();
useAwarenessToasts(yjs, t, addToast);
@@ -225,6 +228,24 @@ export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl,
+
{callbackUrl && callbackToken && (
diff --git a/apps/planner/app/components/WaypointSidebar.tsx b/apps/planner/app/components/WaypointSidebar.tsx
index a041581..bc98ac9 100644
--- a/apps/planner/app/components/WaypointSidebar.tsx
+++ b/apps/planner/app/components/WaypointSidebar.tsx
@@ -36,8 +36,10 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
}, [yjs.waypoints]);
const deleteWaypoint = useCallback(
- (index: number) => yjs.waypoints.delete(index, 1),
- [yjs.waypoints],
+ (index: number) => {
+ yjs.doc.transact(() => yjs.waypoints.delete(index, 1), "local");
+ },
+ [yjs.doc, yjs.waypoints],
);
const moveWaypoint = useCallback(
@@ -53,7 +55,7 @@ export function WaypointSidebar({ yjs, routeStats }: WaypointSidebarProps) {
yMap.set("lon", data.lon);
if (data.name) yMap.set("name", data.name);
yjs.waypoints.insert(to, [yMap]);
- });
+ }, "local");
},
[yjs.waypoints, yjs.doc],
);
diff --git a/apps/planner/app/lib/use-undo.test.ts b/apps/planner/app/lib/use-undo.test.ts
new file mode 100644
index 0000000..0ec0fe0
--- /dev/null
+++ b/apps/planner/app/lib/use-undo.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from "vitest";
+import * as Y from "yjs";
+
+describe("Y.UndoManager with tracked origins", () => {
+ it("tracks mutations with 'local' origin", () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) });
+
+ doc.transact(() => arr.push(["a"]), "local");
+ expect(arr.toArray()).toEqual(["a"]);
+ expect(um.undoStack.length).toBe(1);
+
+ um.undo();
+ expect(arr.toArray()).toEqual([]);
+ expect(um.redoStack.length).toBe(1);
+
+ um.redo();
+ expect(arr.toArray()).toEqual(["a"]);
+ });
+
+ it("does not track mutations without 'local' origin", () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], { trackedOrigins: new Set(["local"]) });
+
+ // No origin
+ doc.transact(() => arr.push(["init"]));
+ expect(arr.toArray()).toEqual(["init"]);
+ expect(um.undoStack.length).toBe(0);
+
+ // Different origin
+ doc.transact(() => arr.push(["remote"]), "remote");
+ expect(arr.toArray()).toEqual(["init", "remote"]);
+ expect(um.undoStack.length).toBe(0);
+ });
+
+ it("groups rapid changes within captureTimeout", async () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], {
+ trackedOrigins: new Set(["local"]),
+ captureTimeout: 100,
+ });
+
+ doc.transact(() => arr.push(["a"]), "local");
+ doc.transact(() => arr.push(["b"]), "local");
+ // Both within timeout — should be one undo step
+ expect(um.undoStack.length).toBe(1);
+
+ um.undo();
+ expect(arr.toArray()).toEqual([]);
+ });
+
+ it("stopCapturing separates undo steps", () => {
+ const doc = new Y.Doc();
+ const arr = doc.getArray("test");
+ const um = new Y.UndoManager([arr], {
+ trackedOrigins: new Set(["local"]),
+ captureTimeout: 10000,
+ });
+
+ doc.transact(() => arr.push(["a"]), "local");
+ um.stopCapturing();
+ doc.transact(() => arr.push(["b"]), "local");
+
+ expect(um.undoStack.length).toBe(2);
+
+ um.undo();
+ expect(arr.toArray()).toEqual(["a"]);
+
+ um.undo();
+ expect(arr.toArray()).toEqual([]);
+ });
+
+ it("tracks multiple shared types", () => {
+ const doc = new Y.Doc();
+ const waypoints = doc.getArray("waypoints");
+ const noGoAreas = doc.getArray("noGoAreas");
+ const notes = doc.getText("notes");
+ const um = new Y.UndoManager([waypoints, noGoAreas, notes], {
+ trackedOrigins: new Set(["local"]),
+ });
+
+ doc.transact(() => waypoints.push(["wp1"]), "local");
+ um.stopCapturing();
+ doc.transact(() => notes.insert(0, "hello"), "local");
+ um.stopCapturing();
+ doc.transact(() => noGoAreas.push(["nogo1"]), "local");
+
+ expect(um.undoStack.length).toBe(3);
+
+ // Undo in reverse order
+ um.undo(); // undo noGoAreas
+ expect(noGoAreas.toArray()).toEqual([]);
+ expect(notes.toString()).toBe("hello");
+
+ um.undo(); // undo notes
+ expect(notes.toString()).toBe("");
+ expect(waypoints.toArray()).toEqual(["wp1"]);
+
+ um.undo(); // undo waypoints
+ expect(waypoints.toArray()).toEqual([]);
+ });
+});
diff --git a/apps/planner/app/lib/use-undo.ts b/apps/planner/app/lib/use-undo.ts
new file mode 100644
index 0000000..f4d1fa3
--- /dev/null
+++ b/apps/planner/app/lib/use-undo.ts
@@ -0,0 +1,58 @@
+import { useState, useEffect, useCallback } from "react";
+import * as Y from "yjs";
+
+export function useUndo(undoManager: Y.UndoManager | null): { canUndo: boolean; canRedo: boolean; undo: () => void; redo: () => void } {
+ const [canUndo, setCanUndo] = useState(false);
+ const [canRedo, setCanRedo] = useState(false);
+
+ useEffect(() => {
+ if (!undoManager) return;
+ const update = () => {
+ setCanUndo(undoManager.undoStack.length > 0);
+ setCanRedo(undoManager.redoStack.length > 0);
+ };
+ undoManager.on("stack-item-added", update);
+ undoManager.on("stack-item-popped", update);
+ undoManager.on("stack-item-updated", update);
+ update();
+ return () => {
+ undoManager.off("stack-item-added", update);
+ undoManager.off("stack-item-popped", update);
+ undoManager.off("stack-item-updated", update);
+ };
+ }, [undoManager]);
+
+ const undo = useCallback(() => undoManager?.undo(), [undoManager]);
+ const redo = useCallback(() => undoManager?.redo(), [undoManager]);
+
+ return { canUndo, canRedo, undo, redo };
+}
+
+export function useUndoShortcuts(undoManager: Y.UndoManager | null) {
+ useEffect(() => {
+ if (!undoManager) return;
+
+ const handler = (e: KeyboardEvent) => {
+ // Suppress in text inputs — let browser-native undo handle those
+ const tag = (e.target as HTMLElement)?.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) {
+ return;
+ }
+
+ const isMac = navigator.platform.includes("Mac");
+ const mod = isMac ? e.metaKey : e.ctrlKey;
+ if (!mod) return;
+
+ if (e.key === "z" && !e.shiftKey) {
+ e.preventDefault();
+ undoManager.undo();
+ } else if ((e.key === "z" && e.shiftKey) || e.key === "y") {
+ e.preventDefault();
+ undoManager.redo();
+ }
+ };
+
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [undoManager]);
+}
diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts
index bec224d..95135dd 100644
--- a/apps/planner/app/lib/use-yjs.ts
+++ b/apps/planner/app/lib/use-yjs.ts
@@ -34,6 +34,7 @@ export interface YjsState {
routeData: Y.Map;
noGoAreas: Y.Array>;
notes: Y.Text;
+ undoManager: Y.UndoManager;
awareness: WebsocketProvider["awareness"];
connected: boolean;
setUserName: (name: string) => void;
@@ -119,6 +120,11 @@ export function useYjs(
});
}
+ const undoManager = new Y.UndoManager([waypoints, noGoAreas, notes], {
+ captureTimeout: 500,
+ trackedOrigins: new Set(["local"]),
+ });
+
const updateState = (connected: boolean) => {
setState({
doc,
@@ -127,6 +133,7 @@ export function useYjs(
routeData,
noGoAreas,
notes,
+ undoManager,
awareness: provider.awareness,
connected,
setUserName,
@@ -143,6 +150,7 @@ export function useYjs(
clearInterval(saveInterval);
// Clear localStorage on clean disconnect (session close)
try { localStorage.removeItem(storageKey); } catch { /* ignore */ }
+ undoManager.destroy();
provider.destroy();
doc.destroy();
providerRef.current = null;
diff --git a/openspec/changes/undo-redo/.openspec.yaml b/openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml
similarity index 100%
rename from openspec/changes/undo-redo/.openspec.yaml
rename to openspec/changes/archive/2026-04-04-undo-redo/.openspec.yaml
diff --git a/openspec/changes/undo-redo/design.md b/openspec/changes/archive/2026-04-04-undo-redo/design.md
similarity index 100%
rename from openspec/changes/undo-redo/design.md
rename to openspec/changes/archive/2026-04-04-undo-redo/design.md
diff --git a/openspec/changes/undo-redo/proposal.md b/openspec/changes/archive/2026-04-04-undo-redo/proposal.md
similarity index 100%
rename from openspec/changes/undo-redo/proposal.md
rename to openspec/changes/archive/2026-04-04-undo-redo/proposal.md
diff --git a/openspec/changes/undo-redo/tasks.md b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md
similarity index 76%
rename from openspec/changes/undo-redo/tasks.md
rename to openspec/changes/archive/2026-04-04-undo-redo/tasks.md
index bde74b7..5c138d8 100644
--- a/openspec/changes/undo-redo/tasks.md
+++ b/openspec/changes/archive/2026-04-04-undo-redo/tasks.md
@@ -1,8 +1,8 @@
## 1. Core: UndoManager Setup
-- [ ] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`)
-- [ ] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function.
-- [ ] 1.3 Add `"local"` origin to all mutation sites:
+- [x] 1.1 Create `apps/planner/app/lib/use-undo.ts` hook that takes a `Y.UndoManager` and exposes `canUndo`, `canRedo`, `undo()`, `redo()` via UndoManager event listeners (`stack-item-added`, `stack-item-popped`, `stack-item-updated`)
+- [x] 1.2 Create the `Y.UndoManager` in `use-yjs.ts`, tracking `[waypoints, noGoAreas, notes]` with `captureTimeout: 500` and `trackedOrigins: new Set(["local"])`. Expose it on the `YjsState` interface. Destroy it in the cleanup function.
+- [x] 1.3 Add `"local"` origin to all mutation sites:
- `PlannerMap.tsx`: wrap `addWaypoint`, `insertWaypointAtSegment`, `moveWaypoint`, `deleteWaypoint` in `doc.transact(() => { ... }, "local")`
- `WaypointSidebar.tsx`: wrap `deleteWaypoint` and `moveWaypoint` in `doc.transact(() => { ... }, "local")`
- `NoGoAreaLayer.tsx`: add `"local"` origin to the `pm:create` and contextmenu delete transactions
@@ -11,24 +11,24 @@
## 2. Keyboard Shortcuts
-- [ ] 2.1 Create a `useUndoShortcuts` hook (in `use-undo.ts` or separate file) that registers a global `keydown` listener for Ctrl+Z / Cmd+Z (undo) and Ctrl+Shift+Z / Cmd+Shift+Z / Ctrl+Y (redo). Calls `undoManager.undo()` / `undoManager.redo()` and calls `e.preventDefault()`.
-- [ ] 2.2 Suppress the shortcut when the active element is ``, `