fix: show error dialog on shared URL failure and fix import annotation merge (#205)

* chore: open v0.10.0 pre-release fix branch

Tracking two bugs found during pre-release testing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: show error dialog on shared URL failure and fix import annotation merge

Bug 1: When a shared URL fails to load (truncated hash, expired paste,
missing encryption key), the portal now shows a warning dialog explaining
the failure and an amber "Demo" badge so users know they're seeing sample
content — not their data.

Bug 2: Importing a teammate's share URL no longer replaces existing
annotations. Uses React functional state updaters to avoid stale closures
and passes the full merged annotation list to pendingSharedAnnotations so
DOM highlights are re-applied for all annotations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Ramos
2026-03-02 16:42:56 -08:00
committed by GitHub
parent ce8946b065
commit 1ea075b241
3 changed files with 70 additions and 19 deletions
+13
View File
@@ -453,6 +453,8 @@ const App: React.FC = () => {
clearPendingSharedAnnotations,
generateShortUrl,
importFromShareUrl,
shareLoadError,
clearShareLoadError,
} = useSharing(
markdown,
annotations,
@@ -1277,6 +1279,7 @@ const App: React.FC = () => {
isPlanDiffActive={isPlanDiffActive}
onPlanDiffToggle={() => setIsPlanDiffActive(!isPlanDiffActive)}
hasPreviousVersion={!linkedDocHook.isActive && planDiff.hasPreviousVersion}
showDemoBadge={!isApiMode && !isLoadingShared && !isSharedSession}
onOpenLinkedDoc={linkedDocHook.open}
linkedDocInfo={linkedDocHook.isActive ? { filepath: linkedDocHook.filepath!, onBack: linkedDocHook.back } : null}
/>
@@ -1386,6 +1389,16 @@ const App: React.FC = () => {
showCancel
/>
{/* Shared URL load failure warning */}
<ConfirmDialog
isOpen={!!shareLoadError && !isApiMode}
onClose={clearShareLoadError}
title="Shared Plan Could Not Be Loaded"
message={shareLoadError}
subMessage="You are viewing a demo plan. This is sample content — it is not your data or anyone else's."
variant="warning"
/>
{/* Save-to-notes toast */}
{noteSaveToast && (
<div className={`fixed top-16 right-4 z-50 px-3 py-2 rounded-lg text-xs font-medium shadow-lg transition-opacity ${
+10 -2
View File
@@ -33,6 +33,8 @@ interface ViewerProps {
isPlanDiffActive?: boolean;
onPlanDiffToggle?: () => void;
hasPreviousVersion?: boolean;
/** Show amber "Demo" badge (portal mode, no shared content loaded) */
showDemoBadge?: boolean;
}
export interface ViewerHandle {
@@ -93,6 +95,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isPlanDiffActive,
onPlanDiffToggle,
hasPreviousVersion,
showDemoBadge,
onOpenLinkedDoc,
linkedDocInfo,
}, ref) => {
@@ -648,8 +651,8 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
linkedDocInfo ? 'border-2 border-primary' : 'border border-border/50'
}`}
>
{/* Repo info + plan diff badge + linked doc badge - top left */}
{(repoInfo || hasPreviousVersion || linkedDocInfo) && (
{/* Repo info + plan diff badge + demo badge + linked doc badge - top left */}
{(repoInfo || hasPreviousVersion || showDemoBadge || linkedDocInfo) && (
<div className="absolute top-3 left-3 md:top-4 md:left-5 flex flex-col items-start gap-1 text-[9px] text-muted-foreground/50 font-mono">
{repoInfo && !linkedDocInfo && (
<div className="flex items-center gap-1.5">
@@ -674,6 +677,11 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
hasPreviousVersion={hasPreviousVersion ?? false}
/>
)}
{showDemoBadge && !linkedDocInfo && (
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono bg-amber-500/15 text-amber-600 dark:text-amber-400">
Demo
</span>
)}
{linkedDocInfo && (
<div className="flex items-center gap-1.5">
<button
+47 -17
View File
@@ -8,7 +8,7 @@
* - Tracking whether current session is from a shared link
*/
import { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { Annotation, type ImageAttachment } from '../types';
import {
type SharePayload,
@@ -68,6 +68,12 @@ interface UseSharingResult {
/** Import annotations from a teammate's share URL */
importFromShareUrl: (url: string) => Promise<ImportResult>;
/** Error message when a shared URL failed to load on mount */
shareLoadError: string;
/** Clear the share load error */
clearShareLoadError: () => void;
}
@@ -76,8 +82,8 @@ export function useSharing(
annotations: Annotation[],
globalAttachments: ImageAttachment[],
setMarkdown: (m: string) => void,
setAnnotations: (a: Annotation[]) => void,
setGlobalAttachments: (g: ImageAttachment[]) => void,
setAnnotations: React.Dispatch<React.SetStateAction<Annotation[]>>,
setGlobalAttachments: React.Dispatch<React.SetStateAction<ImageAttachment[]>>,
onSharedLoad?: () => void,
shareBaseUrl?: string,
pasteApiUrl?: string
@@ -91,12 +97,15 @@ export function useSharing(
const [shortUrlError, setShortUrlError] = useState('');
const [pendingSharedAnnotations, setPendingSharedAnnotations] = useState<Annotation[] | null>(null);
const [sharedGlobalAttachments, setSharedGlobalAttachments] = useState<ImageAttachment[] | null>(null);
const [shareLoadError, setShareLoadError] = useState('');
const clearPendingSharedAnnotations = useCallback(() => {
setPendingSharedAnnotations(null);
setSharedGlobalAttachments(null);
}, []);
const clearShareLoadError = useCallback(() => setShareLoadError(''), []);
// Load shared state from URL hash (or paste-service short URL)
const loadFromHash = useCallback(async () => {
try {
@@ -135,9 +144,11 @@ export function useSharing(
}
// Paste fetch failed — short URL path can't fall back to hash parsing
// (the hash contains #key=, not plan data).
setShareLoadError('Failed to load shared plan — the link may be expired or incomplete.');
return false;
}
const hash = window.location.hash.slice(1);
const payload = await parseShareHash();
if (payload) {
@@ -173,9 +184,15 @@ export function useSharing(
return true;
}
// Hash was present but failed to decompress (likely truncated by browser)
if (hash) {
setShareLoadError('Failed to load shared plan — the URL may have been truncated by your browser.');
}
return false;
} catch (e) {
console.error('Failed to load from share hash:', e);
setShareLoadError('Failed to load shared plan — an unexpected error occurred.');
return false;
}
}, [setMarkdown, setAnnotations, setGlobalAttachments, onSharedLoad, pasteApiUrl]);
@@ -297,8 +314,9 @@ export function useSharing(
return { success: true, count: 0, planTitle, error: 'No annotations found in share link' };
}
// Deduplicate: skip annotations that already exist (by originalText + type + text)
const newAnnotations = importedAnnotations.filter(imp =>
// Estimate count from current closure (may be slightly stale, but
// the actual merge below uses the latest state via functional updater)
const estimatedNew = importedAnnotations.filter(imp =>
!annotations.some(existing =>
existing.originalText === imp.originalText &&
existing.type === imp.type &&
@@ -306,26 +324,36 @@ export function useSharing(
)
);
if (newAnnotations.length > 0) {
// Merge: append new annotations to existing ones
setAnnotations([...annotations, ...newAnnotations]);
// Set as pending so they get applied to DOM highlights
setPendingSharedAnnotations(newAnnotations);
if (estimatedNew.length > 0) {
// Merge using functional updater to avoid stale closure
setAnnotations(prev => {
const newAnnotations = importedAnnotations.filter(imp =>
!prev.some(existing =>
existing.originalText === imp.originalText &&
existing.type === imp.type &&
existing.text === imp.text
)
);
if (newAnnotations.length === 0) return prev;
const merged = [...prev, ...newAnnotations];
// Set ALL annotations as pending so DOM highlights include originals
setPendingSharedAnnotations(merged);
return merged;
});
// Handle global attachments (deduplicate by path)
if (payload.g?.length) {
const parsed = parseShareableImages(payload.g) ?? [];
const existingPaths = new Set(globalAttachments.map(g => g.path));
const newAttachments = parsed.filter(p => !existingPaths.has(p.path));
if (newAttachments.length > 0) {
setGlobalAttachments([...globalAttachments, ...newAttachments]);
}
setGlobalAttachments(prev => {
const existingPaths = new Set(prev.map(g => g.path));
const newAttachments = parsed.filter(p => !existingPaths.has(p.path));
return newAttachments.length > 0 ? [...prev, ...newAttachments] : prev;
});
setSharedGlobalAttachments(parsed);
}
}
return { success: true, count: newAnnotations.length, planTitle };
return { success: true, count: estimatedNew.length, planTitle };
} catch (e) {
const errorMessage = e instanceof Error ? e.message : 'Failed to decompress share URL';
return { success: false, count: 0, planTitle: '', error: errorMessage };
@@ -346,5 +374,7 @@ export function useSharing(
refreshShareUrl,
generateShortUrl,
importFromShareUrl,
shareLoadError,
clearShareLoadError,
};
}