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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ const App: React.FC = () => {
clearPendingSharedAnnotations,
generateShortUrl,
importFromShareUrl,
shareLoadError,
clearShareLoadError,
} = useSharing(
markdown,
annotations,
Expand Down Expand Up @@ -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}
/>
Expand Down Expand Up @@ -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 ${
Expand Down
12 changes: 10 additions & 2 deletions packages/ui/components/Viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -93,6 +95,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
isPlanDiffActive,
onPlanDiffToggle,
hasPreviousVersion,
showDemoBadge,
onOpenLinkedDoc,
linkedDocInfo,
}, ref) => {
Expand Down Expand Up @@ -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">
Expand All @@ -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
Expand Down
64 changes: 47 additions & 17 deletions packages/ui/hooks/useSharing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}


Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]);
Expand Down Expand Up @@ -297,35 +314,46 @@ 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 &&
existing.text === imp.text
)
);

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 };
Expand All @@ -346,5 +374,7 @@ export function useSharing(
refreshShareUrl,
generateShortUrl,
importFromShareUrl,
shareLoadError,
clearShareLoadError,
};
}