From 5f70285b1927c340826d58db79bb704842b0281c Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 6 Aug 2026 20:58:37 -0400 Subject: [PATCH 1/7] feat(collaboration): portable snapshots and navigable map comments A guest could join a session and see an empty map: snapshots kept local file references and control-managed vector data the collaborator cannot read, so shared layers now embed their features and drop the reloadable flag. Guests also arrive at the host's viewport instead of waiting for the host's next move, and the session Copy button yields a joinable URL. Comments become reachable from the map: clicking a pin reveals, highlights and scrolls to its card, the panel ships collapsed on the Style rail so it is discoverable, comments are saved with the project, and "C" places a new one from the command palette. --- .../components/comments/CommentMapOverlay.tsx | 14 +++- .../src/components/comments/CommentThread.tsx | 3 + .../src/components/comments/CommentsPanel.tsx | 84 +++++++++++++++---- .../src/components/layout/DesktopShell.tsx | 9 +- .../src/components/layout/SettingsDialog.tsx | 1 - .../src/components/layout/TopToolbar.tsx | 12 +++ .../src/hooks/useCollaboration.ts | 27 +++++- .../src/hooks/useProjectFileActions.ts | 1 + .../src/hooks/useRegisterCommentsPanel.ts | 8 +- .../src/lib/build-project-snapshot.ts | 45 ++++++++++ .../src/lib/collaboration-layers.ts | 27 ++++++ tests/collaboration-snapshot.test.ts | 48 +++++++++++ 12 files changed, 253 insertions(+), 26 deletions(-) create mode 100644 apps/geolibre-desktop/src/lib/collaboration-layers.ts create mode 100644 tests/collaboration-snapshot.test.ts diff --git a/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx b/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx index f9ab23589..e4fe74420 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentMapOverlay.tsx @@ -131,11 +131,18 @@ export function CommentMapOverlay({ const pinColor = comment.author?.color || "#3b82f6"; + // MapLibre writes its geographic translate transform onto the marker + // element itself. Keep that outer element transform-free and animate a + // child instead; a hover transform on `container` would replace + // MapLibre's translate and make the marker jump across the viewport. const container = document.createElement("div"); - container.className = - "group relative cursor-pointer select-none transition-transform duration-150 ease-out hover:scale-[1.15]"; + container.className = "relative cursor-pointer select-none"; container.style.zIndex = comment.resolved ? "9" : "10"; + const hoverTarget = document.createElement("div"); + hoverTarget.className = + "origin-bottom transition-transform duration-150 ease-out hover:scale-[1.15]"; + // Build the pin with DOM APIs so the author color is set as a style // property, never interpolated into markup — defense-in-depth against // a hand-edited project file with a hostile color value. @@ -159,7 +166,8 @@ export function CommentMapOverlay({ "transform:rotate(45deg);color:#ffffff;font-size:11px;font-weight:700;font-family:system-ui,sans-serif;line-height:1"; label.textContent = `#${idx + 1}`; pin.appendChild(label); - container.appendChild(pin); + hoverTarget.appendChild(pin); + container.appendChild(hoverTarget); container.addEventListener("click", (e) => { e.stopPropagation(); diff --git a/apps/geolibre-desktop/src/components/comments/CommentThread.tsx b/apps/geolibre-desktop/src/components/comments/CommentThread.tsx index fc1a0d28b..e78c126dc 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentThread.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentThread.tsx @@ -21,6 +21,7 @@ interface CommentThreadProps { onDelete: (commentId: string) => void; onZoomTo: (comment: ProjectComment) => void; readOnly?: boolean; + selected?: boolean; } export function CommentThread({ @@ -31,6 +32,7 @@ export function CommentThread({ onDelete, onZoomTo, readOnly = false, + selected = false, }: CommentThreadProps) { const { t } = useTranslation(); const [replyText, setReplyText] = useState(""); @@ -51,6 +53,7 @@ export function CommentThread({ comment.resolved ? "bg-muted/30 border-border/40 opacity-70" : "bg-card border-border shadow-xs hover:border-border/80", + selected && "border-primary ring-1 ring-inset ring-primary", )} > {/* Thread Header */} diff --git a/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx b/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx index 6ab5debec..a47021e01 100644 --- a/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx +++ b/apps/geolibre-desktop/src/components/comments/CommentsPanel.tsx @@ -51,6 +51,10 @@ interface CommentsPanelProps { /** Called when the resolved-pins visibility should change. Receives `true` * when the "Resolved" or "All" filter is active, `false` for "Open". */ onShowResolvedChange?: (showResolved: boolean) => void; + /** Comment selected from its map marker. The matching card is revealed, + * highlighted, and scrolled into view. */ + selectedCommentId?: string | null; + onClearSelectedComment?: () => void; } export function CommentsPanel({ @@ -59,6 +63,8 @@ export function CommentsPanel({ onActivateCommentTool, isCommentToolActive, onShowResolvedChange, + selectedCommentId, + onClearSelectedComment, }: CommentsPanelProps) { const comments = useAppStore((s) => s.comments); const replyToComment = useAppStore((s) => s.replyToComment); @@ -67,6 +73,36 @@ export function CommentsPanel({ const collab = useAppStore((s) => s.collaboration); const [filter, setFilter] = useState<"all" | "open" | "resolved">("open"); + const commentCardRefs = useRef(new Map()); + const revealedSelectionRef = useRef(null); + + useEffect(() => { + if (!selectedCommentId) { + revealedSelectionRef.current = null; + return; + } + if (revealedSelectionRef.current === selectedCommentId) return; + const selected = comments.find((comment) => comment.id === selectedCommentId); + if (!selected) return; + revealedSelectionRef.current = selectedCommentId; + + // A resolved marker can remain visible after the panel was displaced and + // remounted with its default Open filter. Reveal whichever filter contains + // the selected card before trying to scroll to it. + if ((selected.resolved && filter === "open") || (!selected.resolved && filter === "resolved")) { + setFilter(selected.resolved ? "resolved" : "open"); + } + }, [comments, filter, selectedCommentId]); + + useEffect(() => { + if (!selectedCommentId) return; + const frame = requestAnimationFrame(() => { + commentCardRefs.current + .get(selectedCommentId) + ?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }); + return () => cancelAnimationFrame(frame); + }, [filter, selectedCommentId]); // Notify parent whenever resolved pins should show/hide so the map overlay // stays in sync with the sidebar filter. @@ -112,10 +148,12 @@ export function CommentsPanel({ // The real session code comes from the store once start() has resolved. const activeCode = collab.sessionId ?? ""; - const handleCopyCode = async () => { + const handleCopySessionUrl = async () => { if (!activeCode) return; try { - await navigator.clipboard.writeText(activeCode); + const sessionUrl = new URL(window.location.href); + sessionUrl.searchParams.set("collab", activeCode); + await navigator.clipboard.writeText(sessionUrl.toString()); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch { @@ -225,6 +263,9 @@ export function CommentsPanel({ > Add Comment + + C + )} @@ -299,9 +340,9 @@ export function CommentsPanel({ type="button" variant="outline" size="sm" - onClick={handleCopyCode} + onClick={handleCopySessionUrl} className="h-7 px-2 text-[11px] shrink-0" - title="Copy session code" + title="Copy session URL" > {copied ? "Copied" : "Copy"} @@ -392,7 +433,10 @@ export function CommentsPanel({ diff --git a/apps/geolibre-desktop/src/i18n/locales/en.json b/apps/geolibre-desktop/src/i18n/locales/en.json index b043a1ef0..70840f530 100644 --- a/apps/geolibre-desktop/src/i18n/locales/en.json +++ b/apps/geolibre-desktop/src/i18n/locales/en.json @@ -5379,6 +5379,8 @@ "authorNamePlaceholder": "e.g. Alex or Sarah", "commentLabel": "Comment / Feedback", "commentPlaceholder": "Type your review note or feedback here...", + "post": "Post Comment", + "postShortcutTooltip": "Post Comment ({{shortcut}})", "confirmDelete": "Are you sure you want to delete this comment thread?", "sessionDisconnected": "Session disconnected.", "defaultAuthorName": "Author" From 9d7b71edcc75f9a36ece8bdeb0ed12cb5d351fbe Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 6 Aug 2026 22:05:57 -0400 Subject: [PATCH 6/7] Address review feedback - Store a hosted snapshot in chunks across SQLite rows. A Durable Object caps a SQLite string at 2 MB just as it caps a key/value entry, so the one-row table did not actually admit the 10 MB the cap now allows. - buildCollaborationSnapshot re-reads and re-materializes (bounded) when an edit lands mid-read, so layerGroups and selectedLayerId can no longer name a layer the broadcast does not carry. - sendSnapshot reports a build failure only while the request may still broadcast, so it cannot overwrite a relay error that paused sync. - Correct the snapshot-cap comments and docs: 32 MiB is the received-message ceiling, and note the separate 2 MB storage bound. - Update the viewer-mode shortcut comment, which no longer held once add.comment started carrying a shortcut. --- .../src/components/layout/TopToolbar.tsx | 5 +- .../src/hooks/useCollaboration.ts | 8 ++- .../src/lib/build-project-snapshot.ts | 26 ++++++--- docs/collaboration.md | 12 ++-- packages/collab-core/src/session.ts | 6 +- workers/collab/src/session.ts | 58 ++++++++++++++----- 6 files changed, 78 insertions(+), 37 deletions(-) diff --git a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx index fda7f220e..fd5298285 100644 --- a/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx +++ b/apps/geolibre-desktop/src/components/layout/TopToolbar.tsx @@ -1748,8 +1748,9 @@ export function TopToolbar({ // The shortcut layer is narrowed rather than switched off, because the View // menu *does* stay visible in this mode: `view.*` is camera and theme work // only, so dropping its keys would leave those items clickable but silently - // keyless. Every command carrying a `shortcut` is either `view.*` or - // `project.*`, so this is the whole authoring keyboard surface. + // keyless. Everything else carrying a `shortcut` authors the project + // (`project.*`, `add.comment`), so filtering to `view.*` drops exactly the + // authoring keyboard surface. const shortcutCommands = useMemo( () => (viewer ? commands.filter((command) => command.id.startsWith("view.")) : commands), [commands, viewer], diff --git a/apps/geolibre-desktop/src/hooks/useCollaboration.ts b/apps/geolibre-desktop/src/hooks/useCollaboration.ts index e5e85553a..89c18c145 100644 --- a/apps/geolibre-desktop/src/hooks/useCollaboration.ts +++ b/apps/geolibre-desktop/src/hooks/useCollaboration.ts @@ -88,9 +88,11 @@ export function useCollaboration( } catch { // Materializing control-managed vector data can fail (the plugin chunk // or a DuckDB query). Surface it so the participant knows their edits - // stopped propagating, but only for the newest request: a stale failure - // must not overwrite the state of the broadcast that superseded it. - if (request === snapshotRequestRef.current) { + // stopped propagating, but only for the newest request that is still + // allowed to broadcast: a stale failure, or one racing a relay error + // that already paused sync, must not overwrite the message the user + // needs to see. + if (request === snapshotRequestRef.current && canEdit() && !syncPausedRef.current) { useAppStore.getState().setCollaboration({ error: i18n.t("collaborate.shareFailed") }); } return; diff --git a/apps/geolibre-desktop/src/lib/build-project-snapshot.ts b/apps/geolibre-desktop/src/lib/build-project-snapshot.ts index 1eac2b2d6..aaf207aee 100644 --- a/apps/geolibre-desktop/src/lib/build-project-snapshot.ts +++ b/apps/geolibre-desktop/src/lib/build-project-snapshot.ts @@ -78,17 +78,25 @@ export async function buildCollaborationSnapshot( // Keep the plugins barrel out of this module's eager dependency graph: some // optional controls load browser-only SDKs at module evaluation time. const { materializeEmbeddableVectorLayers } = await import("@geolibre/plugins"); - // Read the layer array once, after the import, and feed that same array to - // both steps. Materializing against one revision and applying the result to - // a later one would strip `localFileReloadable` from a layer added during - // the await while leaving it without embedded features — a layer that looks - // portable and arrives empty. A layer added after this read is simply absent - // from this snapshot; the store change that added it schedules the next one. - const source = useAppStore.getState().layers; - const materialized = await materializeEmbeddableVectorLayers(source); + // Materialize against one layer revision and build the snapshot from that + // same revision. Mixing two would strip `localFileReloadable` from a layer + // added during the await while leaving it without embedded features (a layer + // that looks portable and arrives empty), and would let `layerGroups` or + // `selectedLayerId` name a layer the broadcast does not carry. + let source = useAppStore.getState().layers; + let materialized = await materializeEmbeddableVectorLayers(source); + // An edit that lands mid-read invalidates the result, so read again rather + // than combine revisions. Bounded: a participant editing continuously still + // gets a broadcast, at worst one revision behind on group membership. + for (let attempt = 0; attempt < 3 && useAppStore.getState().layers !== source; attempt += 1) { + source = useAppStore.getState().layers; + materialized = await materializeEmbeddableVectorLayers(source); + } const layers = prepareCollaborationLayers(source, materialized); // Pass the prepared layers only when portability actually rewrote one, so an - // unaffected project still snapshots straight from the live store. + // unaffected project still snapshots straight from the live store. Nothing + // below awaits, so the override and every field `buildProjectSnapshot` reads + // from the store come from the same instant. const changed = layers.some((layer, index) => layer !== source[index]); return redactCredentials(buildProjectSnapshot(mapControllerRef, changed ? { layers } : {})); } diff --git a/docs/collaboration.md b/docs/collaboration.md index b60d3d309..3de66e76a 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -116,11 +116,13 @@ in-memory / per-socket attachment. Server-side enforcement: a `snapshot` from a guest who cannot edit (session `view-only`, or a host-set per-participant view-only override) is dropped with an `error: forbidden`; `set-mode` and `set-participant-mode` require the host token. Oversized snapshots (> 10 MB by -default) are rejected with -`error: too-large`. Hosted snapshots live in the Durable Object's SQLite table -rather than a single key/value entry, allowing portable GeoJSON from local files -and external plugins to exceed the storage API's per-entry limit. An empty -session is reclaimed after a TTL via a storage alarm. +default) are rejected with `error: too-large`; the cap sits under Cloudflare's +32 MiB ceiling on a received WebSocket message. Hosted snapshots are stored in +chunks across rows of a SQLite table, because a Durable Object caps both a +key/value entry and a single SQLite string at 2 MB — well under what portable +GeoJSON from local files and external plugins needs. An empty session is +reclaimed after a TTL via a storage alarm, which drops the chunks with the rest +of the database. ## Frontend diff --git a/packages/collab-core/src/session.ts b/packages/collab-core/src/session.ts index 00edd8739..6076ae13a 100644 --- a/packages/collab-core/src/session.ts +++ b/packages/collab-core/src/session.ts @@ -10,8 +10,10 @@ import { finite, HEX_COLOR_RE } from "./internal/validate"; // Large in-memory/plugin datasets are embedded as GeoJSON in collaboration // snapshots so peers can render them without the originating plugin or local -// file. Keep this comfortably below Cloudflare's 32 MiB inbound WebSocket -// frame ceiling while allowing representative multi-layer datasets. +// file. Keep this comfortably below Cloudflare's 32 MiB ceiling on a received +// WebSocket message while allowing representative multi-layer datasets. Note +// this is only the transport bound: a Durable Object caps a stored SQLite +// string at 2 MB, so `workers/collab` splits a snapshot across rows. export const MAX_SNAPSHOT_BYTES = 10_000_000; export const EMPTY_SESSION_TTL_MS = 2 * 60 * 60 * 1000; export const MAX_CHAT_TEXT_LENGTH = 2000; diff --git a/workers/collab/src/session.ts b/workers/collab/src/session.ts index 651861afe..9713e54e0 100644 --- a/workers/collab/src/session.ts +++ b/workers/collab/src/session.ts @@ -64,6 +64,11 @@ export interface Env { // second), so we don't allocate a new encoder per message. const ENCODER = new TextEncoder(); +// Characters per stored snapshot chunk. A Durable Object caps a SQLite string +// at 2 MB of UTF-8, and a JS string character can encode to 4 bytes, so this +// leaves a chunk at half the ceiling even for text that is entirely non-ASCII. +const SNAPSHOT_CHUNK_CHARS = 256 * 1024; + /** * The shared `SessionParticipant` state, serialized onto a hibernatable socket. * `editOverride`, `lastChatTs`, and `lastCommentTs` ride on the attachment so @@ -97,24 +102,27 @@ export class CollabSession extends DurableObject { return Number.isSafeInteger(configured) && configured > 0 ? configured : MAX_SNAPSHOT_BYTES; } + private ensureSnapshotTable(): void { + this.ctx.storage.sql.exec( + "CREATE TABLE IF NOT EXISTS collab_snapshot_chunks (seq INTEGER PRIMARY KEY, value TEXT NOT NULL)", + ); + } + /** - * Snapshots can exceed Durable Objects' 2 MiB key/value entry limit once - * portable GeoJSON from local files or external plugins is embedded. The - * SQLite-backed class has no such per-value KV ceiling, so keep the project - * in a one-row SQL table. Read the legacy KV key as a migration fallback for - * sessions created before this table existed. + * Read the stored project, reassembled from its chunks. + * + * Falls back to the legacy `snapshot` key/value entry so a session created + * before this table existed still serves late joiners. */ private readSqlSnapshot(): string | undefined { - this.ctx.storage.sql.exec( - "CREATE TABLE IF NOT EXISTS collab_snapshot (id INTEGER PRIMARY KEY CHECK (id = 1), value TEXT NOT NULL)", - ); + this.ensureSnapshotTable(); // Not `.one()`: that throws unless the result set holds exactly one row, // so a session that has not stored a snapshot yet would fail instead of // falling through to the legacy KV read below. - const row = this.ctx.storage.sql - .exec<{ value: string }>("SELECT value FROM collab_snapshot WHERE id = 1") - .toArray()[0]; - return row?.value; + const rows = this.ctx.storage.sql + .exec<{ value: string }>("SELECT value FROM collab_snapshot_chunks ORDER BY seq") + .toArray(); + return rows.length > 0 ? rows.map((row) => row.value).join("") : undefined; } private async readSnapshot(): Promise { @@ -123,11 +131,29 @@ export class CollabSession extends DurableObject { return this.ctx.storage.get("snapshot"); } + /** + * Store the project across as many rows as it needs. + * + * A snapshot outgrew single-value storage once portable GeoJSON from local + * files and external plugins started being embedded: a Durable Object caps a + * key/value entry *and* a SQLite string at 2 MB, while `MAX_SNAPSHOT_BYTES` + * now admits several times that. Only the per-object total (10 GB) bounds a + * run of rows, so the project is split and rejoined on read. + */ private async writeSnapshot(snapshot: string): Promise { - this.ctx.storage.sql.exec( - "INSERT INTO collab_snapshot (id, value) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET value = excluded.value", - snapshot, - ); + this.ensureSnapshotTable(); + this.ctx.storage.sql.exec("DELETE FROM collab_snapshot_chunks"); + for ( + let offset = 0, seq = 0; + offset < snapshot.length; + offset += SNAPSHOT_CHUNK_CHARS, seq += 1 + ) { + this.ctx.storage.sql.exec( + "INSERT INTO collab_snapshot_chunks (seq, value) VALUES (?, ?)", + seq, + snapshot.slice(offset, offset + SNAPSHOT_CHUNK_CHARS), + ); + } // A migrated session must not retain a second, stale copy. await this.ctx.storage.delete("snapshot"); } From 97cdc7be1cfb1ce3f9400e0df0026a91efdbd646 Mon Sep 17 00:00:00 2001 From: giswqs Date: Thu, 6 Aug 2026 22:07:27 -0400 Subject: [PATCH 7/7] Address CodeRabbit review feedback - Set shift: false on POST_COMMENT_SHORTCUT. An omitted shift means "ignored" in matchesShortcut, so the dialog also posted on Ctrl/Cmd+Shift+Enter, a chord the button never advertises. --- .../src/components/comments/AddCommentDialog.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx b/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx index 5a7fc8f73..3042a5e11 100644 --- a/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx +++ b/apps/geolibre-desktop/src/components/comments/AddCommentDialog.tsx @@ -14,7 +14,9 @@ import { MapPin, Layers, MessageSquare, Send, User } from "lucide-react"; import type { PendingCommentState } from "./useCommentTool"; import { formatShortcut, isMacPlatform, matchesShortcut, type Shortcut } from "../../lib/commands"; -export const POST_COMMENT_SHORTCUT: Shortcut = { key: "Enter", mod: true }; +// `shift` is explicit: omitting it means "ignored", which would post on +// Ctrl/⌘+Shift+Enter too, a chord the button never advertises. +export const POST_COMMENT_SHORTCUT: Shortcut = { key: "Enter", mod: true, shift: false }; interface AddCommentDialogProps { pendingComment: PendingCommentState;