From 7b3aa199d9f2b0171f1f81f13928f9f2a61c4fde Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 10:41:33 -0400 Subject: [PATCH 1/2] feat(desktop): canvas version history, restore, and conflict-checked save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the canvas head event id through the TS API and send it as the optimistic-concurrency `expected-revision` on every save. When a concurrent edit moved the head, the relay reject surfaces as a distinct "canvas changed — reload" state instead of a generic error. Add a get_canvas_history command over the retained kind:40100 stream and a history panel with author, timestamp, a line diff against the current content, and a Restore action. Restore publishes a new head carrying the selected revision's content under the same conflict guard — it never mutates or deletes history. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/package.json | 1 + desktop/src-tauri/src/commands/canvas.rs | 113 ++++++++- desktop/src-tauri/src/events.rs | 16 +- desktop/src-tauri/src/lib.rs | 1 + .../features/channels/canvasConflict.test.mjs | 56 +++++ .../src/features/channels/canvasConflict.ts | 57 +++++ desktop/src/features/channels/canvasHooks.ts | 75 ++++++ desktop/src/features/channels/hooks.ts | 42 +--- .../channels/ui/CanvasHistoryPanel.tsx | 237 ++++++++++++++++++ .../features/channels/ui/ChannelCanvas.tsx | 54 +++- .../ui/ChannelCanvasEditSnapshot.test.mjs | 190 ++++++++++++++ desktop/src/shared/api/canvasTypes.ts | 36 +++ .../src/shared/api/relayQueryInvalidation.ts | 1 + desktop/src/shared/api/tauri.ts | 46 +--- desktop/src/shared/api/tauriCanvas.ts | 88 +++++++ desktop/src/shared/api/types.ts | 23 +- desktop/src/testing/e2eBridge.ts | 140 ++++++++++- desktop/tests/e2e/channels.spec.ts | 78 ++++++ desktop/tests/helpers/bridge.ts | 7 + pnpm-lock.yaml | 3 + 20 files changed, 1164 insertions(+), 100 deletions(-) create mode 100644 desktop/src/features/channels/canvasConflict.test.mjs create mode 100644 desktop/src/features/channels/canvasConflict.ts create mode 100644 desktop/src/features/channels/canvasHooks.ts create mode 100644 desktop/src/features/channels/ui/CanvasHistoryPanel.tsx create mode 100644 desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs create mode 100644 desktop/src/shared/api/canvasTypes.ts create mode 100644 desktop/src/shared/api/tauriCanvas.ts diff --git a/desktop/package.json b/desktop/package.json index 4a6bdcd7f56..30de251a294 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -63,6 +63,7 @@ "@tiptap/starter-kit": "^3.22.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "diff": "^8.0.4", "embla-carousel-react": "^8.6.0", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 191fafe60d8..08c8bc15a3a 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -3,6 +3,7 @@ use tauri::State; use crate::{ app_state::AppState, events, + managed_agents::persona_events::monotonic_created_at, relay::{query_relay, submit_event}, }; @@ -46,11 +47,27 @@ pub async fn get_canvas( pub async fn set_canvas( channel_id: String, content: String, + expected_revision: Option, state: State<'_, AppState>, ) -> Result { let uuid = uuid::Uuid::parse_str(&channel_id) .map_err(|_| format!("invalid channel UUID: {channel_id}"))?; - let builder = events::build_set_canvas(uuid, &content)?; + + // Writer discipline (contract v3): sign `created_at = max(now, head + 1)` + // so an accepted tagged write always sorts strictly ahead of the head it + // asserts (`created_at DESC, id ASC`). Without this, a same-second or + // behind-clock writer could satisfy the precondition yet lose the relay's + // tiebreak, "succeeding" without changing the visible canvas. Only a real + // head id has a timestamp to clear; `none`/absent asserts no prior head. + let prior_head_created_at = match expected_revision.as_deref() { + Some(rev) if rev.len() == 64 && rev.bytes().all(|b| b.is_ascii_hexdigit()) => { + asserted_head_created_at(&state, &channel_id, rev).await? + } + _ => None, + }; + + let builder = events::build_set_canvas(uuid, &content, expected_revision.as_deref())? + .custom_created_at(monotonic_created_at(prior_head_created_at)); let result = submit_event(builder, &state).await?; Ok(serde_json::json!({ @@ -58,3 +75,97 @@ pub async fn set_canvas( "event_id": result.event_id, })) } + +/// `created_at` of the asserted head, or `None` if the relay no longer holds +/// that revision. An id-scoped query is immutable, so the answer cannot shift +/// under a concurrent write; a missing head lets the relay surface the +/// `conflict: canvas revision does not exist` reject on submit rather than +/// masking it with a stale floor here. +async fn asserted_head_created_at( + state: &AppState, + channel_id: &str, + revision: &str, +) -> Result, String> { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "ids": [revision], + "limit": 1 + })], + ) + .await?; + Ok(events + .first() + .map(|event| event.created_at.as_secs() as i64)) +} + +/// One page of a channel canvas's revision stream (kind:40100), newest first. +/// Each 40100 write is a regular signed event the relay retains, so the +/// standard query surface holds the complete history. The composite +/// `(until, before_id)` cursor mirrors the relay read order +/// (`created_at DESC, id ASC`) so paging never skips or repeats a revision when +/// several share the same second. `next_cursor` is present only when a full +/// page came back, i.e. older revisions may remain. +#[tauri::command] +pub async fn get_canvas_history( + channel_id: String, + limit: Option, + until: Option, + before_id: Option, + state: State<'_, AppState>, +) -> Result { + if before_id.is_some() && until.is_none() { + return Err("before_id requires until".to_string()); + } + let page_size = limit.unwrap_or(100).max(1); + + let mut filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": page_size, + }); + if let Some(value) = until { + filter["until"] = serde_json::json!(value); + } + if let Some(ref value) = before_id { + if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err("before_id must be a 64-character hex event id".to_string()); + } + filter["before_id"] = serde_json::json!(value); + } + + let events = query_relay(&state, &[filter]).await?; + + let revisions: Vec = events + .iter() + .map(|event| { + serde_json::json!({ + "event_id": event.id.to_hex(), + "content": event.content, + "created_at": event.created_at.as_secs(), + "author": event.pubkey.to_hex(), + }) + }) + .collect(); + + // A full page means the relay may hold older revisions; hand back the + // last event as the cursor for the next "Load older" request. A short page + // is the tail, so there is no next cursor. + let next_cursor = if events.len() == page_size { + events.last().map(|last| { + serde_json::json!({ + "created_at": last.created_at.as_secs(), + "event_id": last.id.to_hex(), + }) + }) + } else { + None + }; + + Ok(serde_json::json!({ + "revisions": revisions, + "next_cursor": next_cursor, + })) +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..c9ae5811d5c 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -416,9 +416,21 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result Result { +/// +/// When `expected_revision` is `Some`, an `["expected-revision", ]` +/// tag is attached so the relay can reject the write if the canvas head moved +/// since the client loaded it (optimistic concurrency). Omitting it preserves +/// the historical unconditional-append behavior. +pub fn build_set_canvas( + channel_id: Uuid, + content: &str, + expected_revision: Option<&str>, +) -> Result { check_content(content)?; - let tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; + if let Some(revision) = expected_revision { + tags.push(tag(vec!["expected-revision", revision])?); + } Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags)) } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 613040b8095..dc4b88630ff 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -635,6 +635,7 @@ pub fn run() { join_channel, leave_channel, get_canvas, + get_canvas_history, set_canvas, get_feed, search_messages, diff --git a/desktop/src/features/channels/canvasConflict.test.mjs b/desktop/src/features/channels/canvasConflict.test.mjs new file mode 100644 index 00000000000..b2eaa9edcb1 --- /dev/null +++ b/desktop/src/features/channels/canvasConflict.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + CANVAS_EXPECTED_REVISION_NONE, + isCanvasConflictError, +} from "./canvasConflict.ts"; + +// The three frozen relay reject strings are all conflicts from the user's +// perspective: the head moved, the revision the client expected no longer +// exists, or the write does not sort strictly ahead of the current head +// (contract v3). The helper must recognize each whether it arrives as an Error +// or a raw string (the Tauri IPC layer hands back either), and must not misfire +// on unrelated errors. + +test("head-moved reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas changed since it was loaded"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("revision-does-not-exist reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas revision does not exist"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("does-not-supersede reject is a conflict as Error and as raw string", () => { + const message = "conflict: canvas write does not supersede the current head"; + assert.equal(isCanvasConflictError(new Error(message)), true); + assert.equal(isCanvasConflictError(message), true); +}); + +test("conflict marker embedded in a longer wrapped message still matches", () => { + const wrapped = new Error( + "submit failed: conflict: canvas revision does not exist (relay)", + ); + assert.equal(isCanvasConflictError(wrapped), true); +}); + +test("unrelated errors are not conflicts", () => { + assert.equal(isCanvasConflictError(new Error("relay unreachable")), false); + assert.equal(isCanvasConflictError("some other failure"), false); + assert.equal(isCanvasConflictError(null), false); + assert.equal(isCanvasConflictError(undefined), false); + assert.equal( + isCanvasConflictError({ + message: "conflict: canvas changed since it was loaded", + }), + false, + ); +}); + +test("the create-race sentinel is the literal contract value", () => { + assert.equal(CANVAS_EXPECTED_REVISION_NONE, "none"); +}); diff --git a/desktop/src/features/channels/canvasConflict.ts b/desktop/src/features/channels/canvasConflict.ts new file mode 100644 index 00000000000..264388986f8 --- /dev/null +++ b/desktop/src/features/channels/canvasConflict.ts @@ -0,0 +1,57 @@ +/** + * Optimistic-concurrency conflict detection for the channel canvas. + * + * A conflict-checked save (`set_canvas` / restore) sends an + * `["expected-revision", ]` tag. The relay rejects the + * write when the live head no longer matches what the client loaded, and the + * Rust submit path surfaces that as an error whose message contains one of the + * frozen relay strings below. Callers use this to render a distinct "canvas + * changed — reload" state instead of a generic error. + * + * Two reject strings are both conflicts from the user's perspective: + * - the head moved since load, and + * - the revision the client expected no longer exists (e.g. it expected a head + * but the canvas was never created, or was replaced out from under it). + * A third arises under contract v3's head-advancement guarantee: a write whose + * precondition matches but which does not sort strictly ahead of the asserted + * head (`created_at DESC, id ASC`) is rejected so an accepted tagged write is + * always the new visible head. + * + * Contract: the relay reject strings are frozen (`crates/**`, Duncan's PR1). Do + * not change these substrings without updating the relay in lockstep. + */ +const CANVAS_CONFLICT_MARKERS = [ + "conflict: canvas changed since it was loaded", + "conflict: canvas revision does not exist", + "conflict: canvas write does not supersede the current head", +] as const; + +export const CANVAS_CONFLICT_MESSAGE = + "This canvas changed since you loaded it — reload to see the latest, then reapply your edit."; + +/** + * Literal `expected-revision` value asserting "I expect no canvas exists yet". + * Sent by the first save of a new canvas so a concurrent first creation is + * rejected as a conflict rather than silently overwritten. Frozen contract + * value (`crates/**`, Duncan's PR1). + */ +export const CANVAS_EXPECTED_REVISION_NONE = "none"; + +/** + * True when `error` is the relay's optimistic-concurrency conflict — the head + * moved or the expected revision no longer exists between the load and the + * save. Accepts `Error` instances and raw strings so callers can pass whatever + * the Tauri IPC layer hands them. + */ +export function isCanvasConflictError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : null; + if (message === null) { + return false; + } + return CANVAS_CONFLICT_MARKERS.some((marker) => message.includes(marker)); +} diff --git a/desktop/src/features/channels/canvasHooks.ts b/desktop/src/features/channels/canvasHooks.ts new file mode 100644 index 00000000000..9e289a5f38c --- /dev/null +++ b/desktop/src/features/channels/canvasHooks.ts @@ -0,0 +1,75 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; + +import { getCanvas, getCanvasHistory, setCanvas } from "@/shared/api/tauri"; +import type { + CanvasHistoryCursor, + CanvasHistoryResponse, +} from "@/shared/api/types"; + +export function useCanvasQuery(channelId: string | null, enabled = true) { + return useQuery({ + queryKey: ["channel-canvas", channelId], + queryFn: () => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvas(channelId); + }, + enabled: enabled && channelId !== null, + }); +} + +export function useCanvasHistoryQuery( + channelId: string | null, + enabled: boolean, +) { + return useInfiniteQuery({ + queryKey: ["channel-canvas-history", channelId], + queryFn: ({ pageParam }) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return getCanvasHistory(channelId, { + cursor: (pageParam as CanvasHistoryCursor | null) ?? null, + }); + }, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + initialPageParam: null, + enabled: enabled && channelId !== null, + }); +} + +export function useSetCanvasMutation(channelId: string | null) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (input: { + content: string; + expectedRevision?: string | null; + }) => { + if (!channelId) { + return Promise.reject(new Error("No channel selected")); + } + return setCanvas({ + channelId, + content: input.content, + expectedRevision: input.expectedRevision ?? null, + }); + }, + onSuccess: () => { + if (channelId) { + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas", channelId], + }); + void queryClient.invalidateQueries({ + queryKey: ["channel-canvas-history", channelId], + }); + } + }, + }); +} diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9069b052da4..8aad4b53c17 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -11,7 +11,6 @@ import { archiveChannel, createChannel, deleteChannel, - getCanvas, getChannelDetails, getChannelMembers, getChannels, @@ -21,7 +20,6 @@ import { openDm, invokeTauri, removeChannelMember, - setCanvas, setChannelPurpose, setChannelTopic, unarchiveChannel, @@ -936,35 +934,11 @@ export function useSelectedChannel( } // ── Canvas ──────────────────────────────────────────────────────────────────── -export function useCanvasQuery(channelId: string | null, enabled = true) { - return useQuery({ - queryKey: ["channel-canvas", channelId], - queryFn: () => { - if (!channelId) { - return Promise.reject(new Error("No channel selected")); - } - return getCanvas(channelId); - }, - enabled: enabled && channelId !== null, - }); -} - -export function useSetCanvasMutation(channelId: string | null) { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (content: string) => { - if (!channelId) { - return Promise.reject(new Error("No channel selected")); - } - return setCanvas({ channelId, content }); - }, - onSuccess: () => { - if (channelId) { - void queryClient.invalidateQueries({ - queryKey: ["channel-canvas", channelId], - }); - } - }, - }); -} +// Canvas query/mutation hooks live in their own module to keep this file under +// the desktop file-size ratchet; re-exported here so existing import paths +// (`@/features/channels/hooks`) keep working. +export { + useCanvasHistoryQuery, + useCanvasQuery, + useSetCanvasMutation, +} from "@/features/channels/canvasHooks"; diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx new file mode 100644 index 00000000000..2d50ac82197 --- /dev/null +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -0,0 +1,237 @@ +import { diffLines } from "diff"; +import { RotateCcw } from "lucide-react"; +import * as React from "react"; + +import { + useCanvasHistoryQuery, + useSetCanvasMutation, +} from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { CanvasRevision } from "@/shared/api/types"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; +import { truncatePubkey } from "@/shared/lib/pubkey"; +import { + isRelayUnreachableError, + RELAY_UNREACHABLE_SHORT, +} from "@/shared/lib/relayError"; +import { + CANVAS_CONFLICT_MESSAGE, + CANVAS_EXPECTED_REVISION_NONE, + isCanvasConflictError, +} from "@/features/channels/canvasConflict"; +import { Button } from "@/shared/ui/button"; + +type CanvasHistoryPanelProps = { + channelId: string; + currentContent: string; + currentRevision: string | null; + canRestore: boolean; +}; + +/** + * Revision history for a channel canvas. Every kind:40100 write is a regular + * signed event the relay retains, so the list is the complete edit stream — + * newest first, the head marked "Current". Selecting an older revision reveals + * a line diff against the current content and (when the viewer can edit) a + * Restore action. + * + * Restore never mutates history: it publishes a new head carrying the selected + * revision's content, guarded by `expected-revision` = the current head so a + * concurrent edit surfaces the same conflict state as a normal save. + */ +export function CanvasHistoryPanel({ + channelId, + currentContent, + currentRevision, + canRestore, +}: CanvasHistoryPanelProps) { + const historyQuery = useCanvasHistoryQuery(channelId, true); + const restoreMutation = useSetCanvasMutation(channelId); + const [selectedId, setSelectedId] = React.useState(null); + + const revisions = React.useMemo( + () => historyQuery.data?.pages.flatMap((page) => page.revisions) ?? [], + [historyQuery.data], + ); + const authorPubkeys = React.useMemo( + () => revisions.map((revision) => revision.author), + [revisions], + ); + const profilesQuery = useUsersBatchQuery(authorPubkeys, { + enabled: authorPubkeys.length > 0, + }); + + function authorLabel(pubkey: string): string { + const summary = profilesQuery.data?.profiles[pubkey.toLowerCase()]; + return summary?.displayName?.trim() || truncatePubkey(pubkey); + } + + async function handleRestore(revision: CanvasRevision) { + // Restore is a conflict-checked publish against the live head: if the + // canvas moved since this panel loaded, the relay rejects and we surface + // the same reload state as a normal save. + await restoreMutation.mutateAsync({ + content: revision.content, + expectedRevision: currentRevision ?? CANVAS_EXPECTED_REVISION_NONE, + }); + setSelectedId(null); + } + + if (historyQuery.isLoading) { + return

Loading history...

; + } + + if (historyQuery.error instanceof Error) { + return ( +

+ {isRelayUnreachableError(historyQuery.error) + ? RELAY_UNREACHABLE_SHORT + : historyQuery.error.message} +

+ ); + } + + if (revisions.length === 0) { + return

No revisions yet.

; + } + + return ( +
+
    + {revisions.map((revision) => { + const isCurrent = revision.eventId === currentRevision; + const isSelected = revision.eventId === selectedId; + return ( +
  • + + {isSelected ? ( +
    + + {canRestore && !isCurrent ? ( + + ) : null} + {restoreMutation.error instanceof Error ? ( +

    + {isCanvasConflictError(restoreMutation.error) + ? CANVAS_CONFLICT_MESSAGE + : restoreMutation.error.message} +

    + ) : null} +
    + ) : null} +
  • + ); + })} +
+ {historyQuery.hasNextPage ? ( + + ) : null} +
+ ); +} + +/** + * Line-level diff of a past revision against the current canvas content. + * Additions are the revision's lines not in current; removals are current + * lines the revision drops. Unchanged runs render muted for context. + */ +function CanvasRevisionDiff({ + current, + revision, +}: { + current: string; + revision: string; +}) { + const parts = React.useMemo( + () => diffLines(current, revision), + [current, revision], + ); + if (parts.length === 1 && !parts[0].added && !parts[0].removed) { + return ( +

+ Identical to the current canvas. +

+ ); + } + // Each part covers a distinct, non-overlapping slice of the concatenated + // diff, so its cumulative character offset is a stable, unique key. + let offset = 0; + return ( +
+      {parts.map((part) => {
+        const prefix = part.added ? "+" : part.removed ? "-" : " ";
+        const tone = part.added
+          ? "text-emerald-600 dark:text-emerald-400"
+          : part.removed
+            ? "text-destructive"
+            : "text-muted-foreground";
+        const key = `${prefix}${offset}`;
+        offset += part.value.length;
+        return (
+          
+            {part.value
+              .replace(/\n$/, "")
+              .split("\n")
+              .map((line) => `${prefix} ${line}`)
+              .join("\n")}
+            {"\n"}
+          
+        );
+      })}
+    
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index 785be39227b..e3b928bb0e4 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -1,4 +1,4 @@ -import { Pencil, Save, X } from "lucide-react"; +import { History, Pencil, Save, X } from "lucide-react"; import * as React from "react"; import { @@ -13,6 +13,12 @@ import { isRelayUnreachableError, RELAY_UNREACHABLE_SHORT, } from "@/shared/lib/relayError"; +import { + CANVAS_CONFLICT_MESSAGE, + CANVAS_EXPECTED_REVISION_NONE, + isCanvasConflictError, +} from "@/features/channels/canvasConflict"; +import { CanvasHistoryPanel } from "./CanvasHistoryPanel"; type ChannelCanvasProps = { channelId: string | null; @@ -33,15 +39,25 @@ export function ChannelCanvas({ [channels], ); const [isEditing, setIsEditing] = React.useState(false); + const [showHistory, setShowHistory] = React.useState(false); const [draft, setDraft] = React.useState(""); + // Head event id captured at edit-start. A background canvas refetch can move + // the live head mid-edit; the save must assert against what the editor + // actually loaded, not the latest head. `null` means "no canvas existed when + // I started" and maps to the `none` create-race sentinel below. + const [editBaseRevision, setEditBaseRevision] = React.useState( + null, + ); const canvasContent = canvasQuery.data?.content ?? null; + const canvasRevision = canvasQuery.data?.eventId ?? null; // Defer the single large Markdown parse so opening the canvas commits the // surrounding chrome immediately and the heavy render reconciles after. const deferredCanvasContent = React.useDeferredValue(canvasContent); function handleStartEditing() { setDraft(canvasContent ?? ""); + setEditBaseRevision(canvasRevision); setIsEditing(true); } @@ -51,7 +67,14 @@ export function ChannelCanvas({ } async function handleSave() { - await setCanvasMutation.mutateAsync(draft); + // Assert against the head snapshotted at edit-start, not the live head — + // a refetch may have moved `canvasRevision` while the editor was open. + // A null snapshot means no canvas existed then, so send the `none` + // sentinel to close the concurrent-first-creation race. + await setCanvasMutation.mutateAsync({ + content: draft, + expectedRevision: editBaseRevision ?? CANVAS_EXPECTED_REVISION_NONE, + }); setIsEditing(false); } @@ -110,7 +133,9 @@ export function ChannelCanvas({ {setCanvasMutation.error instanceof Error ? (

- {setCanvasMutation.error.message} + {isCanvasConflictError(setCanvasMutation.error) + ? CANVAS_CONFLICT_MESSAGE + : setCanvasMutation.error.message}

) : null} @@ -146,6 +171,29 @@ export function ChannelCanvas({ {canvasContent ? "Edit canvas" : "Create canvas"} ) : null} + {canvasContent ? ( + <> + + {showHistory && channelId ? ( + + ) : null} + + ) : null} ); } diff --git a/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs b/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs new file mode 100644 index 00000000000..a9bd1912a9d --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelCanvasEditSnapshot.test.mjs @@ -0,0 +1,190 @@ +/** + * Edit-session snapshot regression: ChannelCanvas must assert the save against + * the head that existed when editing started, not the live head. A background + * canvas refetch can move the head mid-edit; without the snapshot the save + * would silently overwrite the newer revision instead of surfacing a conflict. + * + * Mounts the shipping ChannelCanvas, opens the editor at head A, moves the live + * head to B via a refetch, then saves and asserts the submitted + * `expectedRevision` is still A. + */ + +import assert from "node:assert/strict"; +import { registerHooks } from "node:module"; +import { after, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// The real Markdown component pulls in the remark/rehype/emoji stack, which +// never releases its jsdom handles and hangs the node:test process. This test +// only exercises the save-snapshot wiring, so serve an inert stub. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@/shared/ui/markdown") { + return { shortCircuit: true, url: "buzz-canvas-stub:markdown" }; + } + return nextResolve(specifier, context); + }, + load(url, context, nextLoad) { + if (url === "buzz-canvas-stub:markdown") { + return { + format: "module", + shortCircuit: true, + source: "export function Markdown() { return null; }\n", + }; + } + return nextLoad(url, context); + }, +}); + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +const HEAD_A = "a".repeat(64); +const HEAD_B = "b".repeat(64); + +let React; +let act; +let createRoot; +let QueryClient; +let QueryClientProvider; +let ChannelNavigationProvider; +let ChannelCanvas; + +// Mutable relay-head mock the Tauri bridge reads on each get_canvas. +let currentHead = { content: "original", eventId: HEAD_A }; +const setCanvasCalls = []; + +before(async () => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + window: dom.window, + IS_REACT_ACT_ENVIRONMENT: true, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: dom.window.navigator, + writable: true, + }); + dom.window.matchMedia = () => ({ + matches: false, + addEventListener() {}, + removeEventListener() {}, + }); + + // Stub the Tauri IPC bridge invokeTauri ultimately calls. + dom.window.__TAURI_INTERNALS__ = { + invoke: async (cmd, args) => { + if (cmd === "get_canvas") { + return { + content: currentHead.content, + event_id: currentHead.eventId, + updated_at: 1, + author: HEAD_A, + }; + } + if (cmd === "set_canvas") { + setCanvasCalls.push(args); + return { ok: true, event_id: HEAD_B }; + } + throw new Error(`unexpected command: ${cmd}`); + }, + }; + + ({ default: React, act } = await import("react")); + ({ createRoot } = await import("react-dom/client")); + ({ QueryClient, QueryClientProvider } = await import( + "@tanstack/react-query" + )); + ({ ChannelNavigationProvider } = await import( + "@/shared/context/ChannelNavigationContext" + )); + ({ ChannelCanvas } = await import("./ChannelCanvas.tsx")); +}); + +after(() => dom.window.close()); + +function click(element) { + element.dispatchEvent( + new dom.window.MouseEvent("click", { bubbles: true, cancelable: true }), + ); +} + +// Flush microtasks, pending query promises, and React's scheduler (the +// deferred canvas render is posted on a MessageChannel) so nothing is left +// pending at teardown. +async function settle(iterations = 6) { + for (let i = 0; i < iterations; i++) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + }); + } +} + +test("head moves mid-edit — save still asserts the head snapshotted at edit-start", async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Number.POSITIVE_INFINITY, retry: false }, + // gcTime: 0 lets the settled mutation drop from cache immediately so no + // mutation promise is left pending when the node:test process tears down. + mutations: { gcTime: 0 }, + }, + }); + const container = dom.window.document.createElement("div"); + dom.window.document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + React.createElement( + QueryClientProvider, + { client: queryClient }, + React.createElement( + ChannelNavigationProvider, + { channels: [] }, + React.createElement(ChannelCanvas, { + channelId: "channel-1", + canEdit: true, + isArchived: false, + }), + ), + ), + ); + }); + + // Canvas at head A has loaded; open the editor. + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + const editButton = container.querySelector( + "[data-testid='channel-canvas-edit']", + ); + assert.ok(editButton, "edit button renders after head A loads"); + await act(async () => click(editButton)); + assert.ok(container.querySelector("[data-testid='channel-canvas-editor']")); + + // Head moves to B under the open editor via a background refetch. + currentHead = { content: "moved", eventId: HEAD_B }; + await act(async () => { + await queryClient.refetchQueries({ queryKey: ["channel-canvas"] }); + }); + await settle(); + + // Save — the submitted expected revision must be the snapshot (A), not B. + await act(async () => { + click(container.querySelector("[data-testid='channel-canvas-save']")); + }); + + assert.equal(setCanvasCalls.length, 1); + assert.equal(setCanvasCalls[0].expectedRevision, HEAD_A); + + // Drain the refetch the save's onSuccess invalidation triggers, plus any + // deferred render still scheduled, so no work is pending at teardown. + await settle(12); + await act(async () => root.unmount()); + queryClient.clear(); + container.remove(); +}); diff --git a/desktop/src/shared/api/canvasTypes.ts b/desktop/src/shared/api/canvasTypes.ts new file mode 100644 index 00000000000..f907a80d797 --- /dev/null +++ b/desktop/src/shared/api/canvasTypes.ts @@ -0,0 +1,36 @@ +export type CanvasResponse = { + content: string | null; + eventId: string | null; + updatedAt: number | null; + author: string | null; +}; + +export type SetCanvasInput = { + channelId: string; + content: string; + expectedRevision?: string | null; +}; + +export type SetCanvasResult = { + ok: boolean; + eventId: string; +}; + +export type CanvasRevision = { + eventId: string; + content: string; + createdAt: number; + author: string; +}; + +/** Composite `(created_at DESC, id ASC)` cursor for "Load older" paging. */ +export type CanvasHistoryCursor = { + createdAt: number; + eventId: string; +}; + +export type CanvasHistoryResponse = { + revisions: CanvasRevision[]; + /** Present only when older revisions may remain. */ + nextCursor: CanvasHistoryCursor | null; +}; diff --git a/desktop/src/shared/api/relayQueryInvalidation.ts b/desktop/src/shared/api/relayQueryInvalidation.ts index 6892c54a348..cc04d218d6e 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.ts +++ b/desktop/src/shared/api/relayQueryInvalidation.ts @@ -1,6 +1,7 @@ const RELAY_QUERY_ROOTS = new Set([ "archivedIdentities", "channel-canvas", + "channel-canvas-history", "channel-messages", "channels", "contact-list", diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 6c5b59c9837..bdf65624cba 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -12,7 +12,6 @@ import type { AddChannelMembersResult, BackendProviderCandidate, BackendProviderProbeResult, - CanvasResponse, GetHomeFeedInput, HomeFeedResponse, ManagedAgent, @@ -25,8 +24,6 @@ import type { RelayEvent, SearchMessagesInput, SearchMessagesResponse, - SetCanvasInput, - SetCanvasResult, ThreadCursor, ThreadRepliesResponse, CreateManagedAgentInput, @@ -42,6 +39,11 @@ import type { } from "@/shared/api/types"; export * from "@/shared/api/tauriChannels"; +export { + getCanvas, + getCanvasHistory, + setCanvas, +} from "@/shared/api/tauriCanvas"; export { sendChannelMessage } from "@/shared/api/tauriMessages"; export { getEventById, getEventsByIds } from "@/shared/api/tauriEvents"; @@ -234,17 +236,6 @@ type RawListRelayMembersResponse = { members: RawRelayMember[]; }; -type RawCanvasResponse = { - content: string | null; - updated_at: number | null; - author: string | null; -}; - -type RawSetCanvasResult = { - ok: boolean; - event_id: string; -}; - /** Error normalized from a rejected Tauri invocation with its wire payload. */ export class TauriInvokeError extends Error { readonly payload: unknown; @@ -401,33 +392,6 @@ export async function leaveChannel(channelId: string): Promise { await invokeTauri("leave_channel", { channelId }); } -export async function getCanvas(channelId: string): Promise { - const response = await invokeTauri("get_canvas", { - channelId, - }); - return { - content: response.content, - // Normalize absent keys to null: ensureWelcomeCanvas treats null as - // "no canvas yet", and `undefined !== null` would make every fresh - // channel look already-seeded. - updatedAt: response.updated_at ?? null, - author: response.author ?? null, - }; -} - -export async function setCanvas( - input: SetCanvasInput, -): Promise { - const response = await invokeTauri("set_canvas", { - channelId: input.channelId, - content: input.content, - }); - return { - ok: response.ok, - eventId: response.event_id, - }; -} - export async function getHomeFeed( input: GetHomeFeedInput = {}, ): Promise { diff --git a/desktop/src/shared/api/tauriCanvas.ts b/desktop/src/shared/api/tauriCanvas.ts new file mode 100644 index 00000000000..435f235c0af --- /dev/null +++ b/desktop/src/shared/api/tauriCanvas.ts @@ -0,0 +1,88 @@ +import { invokeTauri } from "@/shared/api/tauri"; +import type { + CanvasHistoryCursor, + CanvasHistoryResponse, + CanvasResponse, + SetCanvasInput, + SetCanvasResult, +} from "@/shared/api/canvasTypes"; + +type RawCanvasResponse = { + content: string | null; + event_id: string | null; + updated_at: number | null; + author: string | null; +}; + +type RawCanvasHistoryResponse = { + revisions: { + event_id: string; + content: string; + created_at: number; + author: string; + }[]; + next_cursor: { created_at: number; event_id: string } | null; +}; + +type RawSetCanvasResult = { + ok: boolean; + event_id: string; +}; + +export async function getCanvas(channelId: string): Promise { + const response = await invokeTauri("get_canvas", { + channelId, + }); + return { + content: response.content, + eventId: response.event_id ?? null, + // Normalize absent keys to null: ensureWelcomeCanvas treats null as + // "no canvas yet", and `undefined !== null` would make every fresh + // channel look already-seeded. + updatedAt: response.updated_at ?? null, + author: response.author ?? null, + }; +} + +export async function setCanvas( + input: SetCanvasInput, +): Promise { + const response = await invokeTauri("set_canvas", { + channelId: input.channelId, + content: input.content, + expectedRevision: input.expectedRevision ?? null, + }); + return { + ok: response.ok, + eventId: response.event_id, + }; +} + +export async function getCanvasHistory( + channelId: string, + options: { limit?: number; cursor?: CanvasHistoryCursor | null } = {}, +): Promise { + const response = await invokeTauri( + "get_canvas_history", + { + channelId, + limit: options.limit ?? null, + until: options.cursor?.createdAt ?? null, + beforeId: options.cursor?.eventId ?? null, + }, + ); + return { + revisions: response.revisions.map((revision) => ({ + eventId: revision.event_id, + content: revision.content, + createdAt: revision.created_at, + author: revision.author, + })), + nextCursor: response.next_cursor + ? { + createdAt: response.next_cursor.created_at, + eventId: response.next_cursor.event_id, + } + : null, + }; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index d1e624ad530..f51db67a9b7 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -69,21 +69,14 @@ export type SetChannelPurposeInput = { purpose: string; }; -export type CanvasResponse = { - content: string | null; - updatedAt: number | null; - author: string | null; -}; - -export type SetCanvasInput = { - channelId: string; - content: string; -}; - -export type SetCanvasResult = { - ok: boolean; - eventId: string; -}; +export type { + CanvasHistoryCursor, + CanvasHistoryResponse, + CanvasResponse, + CanvasRevision, + SetCanvasInput, + SetCanvasResult, +} from "@/shared/api/canvasTypes"; export type AddChannelMembersInput = { channelId: string; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 5bbc160752e..f7d988dad6d 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -347,6 +347,10 @@ type E2eConfig = { deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; + /** Seed canvas revisions (oldest first) so history/restore journeys can + * drive the real panel against a stateful store. Each save appends a new + * head; `get_canvas_history` pages over the accumulated stream. */ + canvasRevisions?: MockCanvasRevisionSeed[]; /** Delay (ms) for `apply_workspace` so e2e tests can observe the * community-switch gate. 0/undefined = instant. */ applyCommunityDelayMs?: number; @@ -3125,6 +3129,49 @@ type MockSaveSubscriptionRow = { }; let mockSaveSubscriptions: MockSaveSubscriptionRow[] = []; +// Stateful mock canvas: an append-only revision stream keyed by channel, newest +// first, mirroring the relay's 40100 history. `get_canvas` returns the head, +// `set_canvas` appends a new head, and `get_canvas_history` pages over the +// stream with the same `(created_at DESC, id ASC)` composite cursor the Rust +// command uses — so history/save-conflict/restore journeys run against real +// state instead of a fixed stub. +type MockCanvasRevisionSeed = { + content: string; + /** Optional; defaults to a monotonically increasing second per seed. */ + createdAt?: number; + /** Optional 64-hex id; defaults to a fresh mock event id. */ + eventId?: string; + /** Optional author pubkey; defaults to the mock viewer. */ + author?: string; +}; +type MockCanvasRevision = { + eventId: string; + content: string; + createdAt: number; + author: string; +}; +let mockCanvasRevisions = new Map(); + +// The canvas UI reaches the mock via the starter "general" channel in specs. +const DEFAULT_STARTER_CANVAS_CHANNEL = STARTER_GENERAL_CHANNEL_ID; + +function resetMockCanvasRevisions(config: E2eConfig | undefined) { + mockCanvasRevisions = new Map(); + const seeds = config?.mock?.canvasRevisions; + if (!seeds || seeds.length === 0) { + return; + } + // Seeds are oldest-first; store newest-first so index 0 is always the head. + const revisions = seeds.map((seed, index) => ({ + eventId: seed.eventId ?? mockEventId(), + content: seed.content, + createdAt: seed.createdAt ?? 1_700_000_000 + index, + author: seed.author ?? DEFAULT_MOCK_IDENTITY.pubkey, + })); + revisions.reverse(); + mockCanvasRevisions.set(DEFAULT_STARTER_CANVAS_CHANNEL, revisions); +} + type MockObservedUnreadScope = { generation: string; revision: number; @@ -10727,6 +10774,7 @@ export function maybeInstallE2eTauriMocks() { resetMockPersonaCatalogEvents(config); resetMockObservedUnread(); resetMockSaveSubscriptions(config); + resetMockCanvasRevisions(config); resetMockPendingCommunityDeepLinks(config); resetMockPendingNavigationDeepLinks(config); resetMockPendingEntityDeepLinks(config); @@ -13816,15 +13864,99 @@ export function maybeInstallE2eTauriMocks() { // The spec only verifies UI state, not the submitted request shape; // returning null mirrors the Rust submit_event success path. return null; - case "set_canvas": - return { ok: true, event_id: mockEventId() }; + case "set_canvas": { + const req = payload as { + channelId: string; + content: string; + expectedRevision?: string | null; + }; + const stream = mockCanvasRevisions.get(req.channelId) ?? []; + const head = stream[0] ?? null; + // Mirror the relay's optimistic-concurrency check: the frozen reject + // strings must survive intact so canvasConflict.ts recognizes them. + const expected = req.expectedRevision; + if (expected !== undefined && expected !== null) { + if (expected === "none" && head) { + throw new Error("conflict: canvas changed since it was loaded"); + } + if (expected !== "none" && !head) { + throw new Error("conflict: canvas revision does not exist"); + } + if (expected !== "none" && head && expected !== head.eventId) { + throw new Error("conflict: canvas changed since it was loaded"); + } + } + const revision: MockCanvasRevision = { + eventId: mockEventId(), + content: req.content, + createdAt: (head?.createdAt ?? 1_700_000_000) + 1, + author: DEFAULT_MOCK_IDENTITY.pubkey, + }; + mockCanvasRevisions.set(req.channelId, [revision, ...stream]); + return { ok: true, event_id: revision.eventId }; + } case "get_canvas": { const canvasReadError = activeConfig?.mock?.canvasReadError; if (canvasReadError) { throw new Error(canvasReadError); } - // Return the no-canvas success shape — content null means no canvas set. - return { content: null, updated_at: null, author: null }; + const req = payload as { channelId: string }; + const head = mockCanvasRevisions.get(req.channelId)?.[0] ?? null; + if (!head) { + // No-canvas success shape — content null means no canvas set. + return { + content: null, + event_id: null, + updated_at: null, + author: null, + }; + } + return { + content: head.content, + event_id: head.eventId, + updated_at: head.createdAt, + author: head.author, + }; + } + case "get_canvas_history": { + const req = payload as { + channelId: string; + limit?: number | null; + until?: number | null; + beforeId?: string | null; + }; + const pageSize = Math.max(req.limit ?? 100, 1); + const stream = mockCanvasRevisions.get(req.channelId) ?? []; + // Keyset over the newest-first stream: strictly older than the + // composite cursor, preserving (created_at DESC, id ASC) so a tied + // second never skips or repeats a revision across a page boundary. + const until = req.until; + const beforeId = req.beforeId; + const windowed = + until == null + ? stream + : stream.filter((rev) => { + if (rev.createdAt < until) return true; + if (rev.createdAt > until) return false; + return beforeId != null && rev.eventId > beforeId; + }); + const page = windowed.slice(0, pageSize); + const nextCursor = + page.length === pageSize && page.length > 0 + ? { + created_at: page[page.length - 1].createdAt, + event_id: page[page.length - 1].eventId, + } + : null; + return { + revisions: page.map((rev) => ({ + event_id: rev.eventId, + content: rev.content, + created_at: rev.createdAt, + author: rev.author, + })), + next_cursor: nextCursor, + }; } // ── Local-save archive ────────────────────────────────────────────── // These stubs drive the LocalArchiveSettingsCard in screenshot / UI tests diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 0e27f771f99..c20832532b0 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2960,6 +2960,84 @@ test("manage channel places canvas between channel info and actions", async ({ expect(canvasBox?.y).toBeLessThan(leaveBox?.y); }); +test("canvas history, save-conflict, and restore journey", async ({ page }) => { + // Seed a two-revision canvas so the history panel has an older revision to + // diff and restore, and the head is the newer one. + await installMockBridge(page, { + canvasRevisions: [ + { content: "# Kickoff\n\nfirst draft", createdAt: 1_700_000_000 }, + { content: "# Kickoff\n\nsecond draft", createdAt: 1_700_000_100 }, + ], + }); + await page.goto("/"); + await openChannelManagement(page, "general"); + await page.getByTestId("channel-canvas-ingress").click(); + + const section = page.getByTestId("channel-canvas-section"); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "second draft", + ); + + // History lists both revisions, newest first with the head marked Current. + await section.getByTestId("channel-canvas-history-toggle").click(); + const historyItems = section.getByTestId("channel-canvas-history-item"); + await expect(historyItems).toHaveCount(2); + await expect(historyItems.first()).toContainText("Current"); + + // Expanding the older revision shows a diff against the current content. + await historyItems.nth(1).getByRole("button").first().click(); + await expect(section.getByTestId("channel-canvas-diff")).toContainText( + "first draft", + ); + + // Save-conflict: open the editor (snapshots head), move the head via a + // concurrent save through the bridge, then save — the frozen relay reject + // must surface as the reload-required conflict copy, not a raw error. + await section.getByTestId("channel-canvas-history-toggle").click(); + await section.getByTestId("channel-canvas-edit").click(); + await page.evaluate((channelId) => { + return ( + window as unknown as { + __TAURI_INTERNALS__: { + invoke: (cmd: string, args: unknown) => Promise; + }; + } + ).__TAURI_INTERNALS__.invoke("set_canvas", { + channelId, + content: "# Kickoff\n\nconcurrent edit", + expectedRevision: null, + }); + }, GENERAL_CHANNEL_ID); + await section.getByTestId("channel-canvas-editor").fill("# Kickoff\n\nmine"); + await section.getByTestId("channel-canvas-save").click(); + await expect(section).toContainText( + "This canvas changed since you loaded it", + ); + + // Cancel, reload the head (reopen the sheet → fresh get_canvas), and restore + // the oldest revision — restore must publish a new head (not mutate history), + // so the count grows. + await section.getByTestId("channel-canvas-cancel").click(); + await closeChannelManagement(page); + await openChannelManagement(page, "general"); + await page.getByTestId("channel-canvas-ingress").click(); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "concurrent edit", + ); + + await section.getByTestId("channel-canvas-history-toggle").click(); + const items = section.getByTestId("channel-canvas-history-item"); + await expect(items).toHaveCount(3); + await items.last().getByRole("button").first().click(); + await section.getByTestId("channel-canvas-restore").click(); + await expect(section.getByTestId("channel-canvas-content")).toContainText( + "first draft", + ); + await expect(section.getByTestId("channel-canvas-history-item")).toHaveCount( + 4, + ); +}); + test("channel settings hides workflows and skips its query when the experiment is disabled", async ({ page, }) => { diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2637b94a808..f3b2bd44042 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -279,6 +279,13 @@ type MockBridgeOptions = { deepHistoryMessageCount?: number; feedReadError?: string; canvasReadError?: string; + /** Seed canvas revisions (oldest first); see e2eBridge mock config. */ + canvasRevisions?: Array<{ + content: string; + createdAt?: number; + eventId?: string; + author?: string; + }>; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ applyCommunityDelayMs?: number; /** Reject `clear_pending_navigation_deep_links` with this message. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11fef7c534d..27cdf711756 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -180,6 +180,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + diff: + specifier: ^8.0.4 + version: 8.0.4 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.8) From f27e5a0d891dda0185a64a606f337222cd6f8ab4 Mon Sep 17 00:00:00 2001 From: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz> Date: Tue, 25 Aug 2026 13:33:22 -0400 Subject: [PATCH 2/2] fix(canvas): address canvas history review nits Gate canvas existence on the persisted revision id, not content truthiness: an empty-string canvas is a valid kind:40100 revision (and restore can republish one), so keying existence, the Create/Edit label, and the History section on content hid retained history for an existing empty canvas. Bound get_canvas_history's page size to the relay read maximum. A request above 1,000 is silently clamped by the relay, which made `events.len() == page_size` false and nulled the cursor even when older revisions remained, stranding them behind an unreachable page. Reject outside 1..=1000. Reset the shared restore mutation on revision selection change so a failed restore's error can no longer render under a different row. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- desktop/src-tauri/src/commands/canvas.rs | 43 ++++- .../channels/ui/CanvasHistoryPanel.tsx | 10 +- .../features/channels/ui/ChannelCanvas.tsx | 13 +- .../ui/ChannelCanvasEmptyExistence.test.mjs | 158 ++++++++++++++++++ 4 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/channels/ui/ChannelCanvasEmptyExistence.test.mjs diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 08c8bc15a3a..cd113f10f15 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -119,7 +119,11 @@ pub async fn get_canvas_history( if before_id.is_some() && until.is_none() { return Err("before_id requires until".to_string()); } - let page_size = limit.unwrap_or(100).max(1); + // Bound the page size to the relay's read maximum. Beyond 1,000 the relay + // silently clamps the returned rows, which would make `events.len() == + // page_size` false and null the cursor even when older revisions remain, + // stranding them behind an unreachable page. + let page_size = resolve_history_page_size(limit)?; let mut filter = serde_json::json!({ "kinds": [40100], @@ -169,3 +173,40 @@ pub async fn get_canvas_history( "next_cursor": next_cursor, })) } + +/// Resolve and validate the history page size against the relay's read +/// maximum. Defaults to 100 when unset; a value outside `1..=1000` is rejected +/// so cursor generation is never based on a size the relay would silently +/// clamp (which strands older revisions behind a falsely-terminated page). +fn resolve_history_page_size(limit: Option) -> Result { + let page_size = limit.unwrap_or(100); + if !(1..=1000).contains(&page_size) { + return Err("limit must be between 1 and 1000".to_string()); + } + Ok(page_size) +} + +#[cfg(test)] +mod tests { + use super::resolve_history_page_size; + + #[test] + fn defaults_to_100_when_unset() { + assert_eq!(resolve_history_page_size(None).unwrap(), 100); + } + + #[test] + fn rejects_zero() { + assert!(resolve_history_page_size(Some(0)).is_err()); + } + + #[test] + fn accepts_relay_maximum() { + assert_eq!(resolve_history_page_size(Some(1000)).unwrap(), 1000); + } + + #[test] + fn rejects_above_relay_maximum() { + assert!(resolve_history_page_size(Some(1001)).is_err()); + } +} diff --git a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx index 2d50ac82197..a6220fb5a87 100644 --- a/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx +++ b/desktop/src/features/channels/ui/CanvasHistoryPanel.tsx @@ -110,9 +110,13 @@ export function CanvasHistoryPanel({ ) : null} - {canvasContent ? ( + {canvasExists ? ( <>