import type { BodyElement } from "../../lib/api";
interface UpdateNotificationProps {
updatedBy: "user" | "llm" | "system";
localBody: BodyElement[];
remoteBody: BodyElement[];
onRefresh: () => void;
onDismiss: () => void;
}
// Get text content from a body element for comparison
function getElementText(element: BodyElement): string {
switch (element.type) {
case "heading":
case "paragraph":
return element.text;
case "chart":
return `[Chart: ${element.title || element.chartType}]`;
case "image":
return `[Image: ${element.alt || element.src}]`;
default:
return "[Unknown element]";
}
}
// Get element type label
function getElementTypeLabel(element: BodyElement): string {
switch (element.type) {
case "heading":
return `H${element.level}`;
case "paragraph":
return "P";
case "chart":
return "Chart";
case "image":
return "Image";
default:
return "?";
}
}
// Word-level diff for showing inline changes
interface WordDiff {
type: "same" | "added" | "removed";
text: string;
}
function computeWordDiff(oldText: string, newText: string): WordDiff[] {
const oldWords = oldText.split(/(\s+)/);
const newWords = newText.split(/(\s+)/);
const result: WordDiff[] = [];
// Simple LCS-based diff
const m = oldWords.length;
const n = newWords.length;
// Build LCS table
const dp: number[][] = Array(m + 1)
.fill(null)
.map(() => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (oldWords[i - 1] === newWords[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to find diff
let i = m,
j = n;
const diffStack: WordDiff[] = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
diffStack.push({ type: "same", text: oldWords[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
diffStack.push({ type: "added", text: newWords[j - 1] });
j--;
} else {
diffStack.push({ type: "removed", text: oldWords[i - 1] });
i--;
}
}
// Reverse and merge consecutive same-type diffs
for (let k = diffStack.length - 1; k >= 0; k--) {
const item = diffStack[k];
if (result.length > 0 && result[result.length - 1].type === item.type) {
result[result.length - 1].text += item.text;
} else {
result.push({ ...item });
}
}
return result;
}
interface DiffItem {
type: "added" | "removed" | "modified" | "unchanged";
localElement?: BodyElement;
remoteElement?: BodyElement;
localIndex?: number;
remoteIndex?: number;
wordDiff?: WordDiff[];
}
// Simple diff algorithm - compares elements by their text content
function computeDiff(localBody: BodyElement[], remoteBody: BodyElement[]): DiffItem[] {
const diffs: DiffItem[] = [];
const maxLen = Math.max(localBody.length, remoteBody.length);
for (let i = 0; i < maxLen; i++) {
const local = localBody[i];
const remote = remoteBody[i];
if (!local && remote) {
// Element added remotely
diffs.push({ type: "added", remoteElement: remote, remoteIndex: i });
} else if (local && !remote) {
// Element removed remotely (exists locally but not remotely)
diffs.push({ type: "removed", localElement: local, localIndex: i });
} else if (local && remote) {
const localText = getElementText(local);
const remoteText = getElementText(remote);
if (localText !== remoteText || local.type !== remote.type) {
// Element modified - compute word-level diff
const wordDiff = computeWordDiff(localText, remoteText);
diffs.push({
type: "modified",
localElement: local,
remoteElement: remote,
localIndex: i,
remoteIndex: i,
wordDiff,
});
}
// Skip unchanged elements
}
}
return diffs;
}
export function UpdateNotification({
updatedBy,
localBody,
remoteBody,
onRefresh,
onDismiss,
}: UpdateNotificationProps) {
const source = updatedBy === "llm" ? "AI assistant" : "another session";
const diffs = computeDiff(localBody, remoteBody);
const hasChanges = diffs.length > 0;
return (
<div className="fixed bottom-4 right-4 w-[480px] max-h-[70vh] flex flex-col bg-[#1a2332] border border-[#3f6fb3]/50 shadow-lg z-50">
{/* Header */}
<div className="p-4 border-b border-[#3f6fb3]/30">
<div className="flex items-start gap-3">
<div className="text-[#75aafc] text-xl font-bold shrink-0">i</div>
<div className="flex-1 min-w-0">
<h3 className="font-mono text-sm text-[#9bc3ff] font-semibold mb-1">
Remote Changes Detected
</h3>
<p className="font-mono text-xs text-white/70">
This file was updated by {source}.
{hasChanges
? ` ${diffs.length} change${diffs.length > 1 ? "s" : ""} found.`
: " No content changes detected."}
</p>
</div>
</div>
</div>
{/* Diff Content */}
{hasChanges && (
<div className="flex-1 overflow-y-auto p-4 space-y-3 min-h-0">
<div className="font-mono text-xs text-[#555] uppercase tracking-wider mb-2">
Changes
</div>
{diffs.map((diff, index) => (
<DiffItemView key={index} diff={diff} />
))}
</div>
)}
{/* Actions */}
<div className="p-4 border-t border-[#3f6fb3]/30 flex gap-2">
<button
onClick={onRefresh}
className="flex-1 px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
>
Accept Remote Changes
</button>
<button
onClick={onDismiss}
className="px-3 py-2 font-mono text-xs text-[#555] border border-[#333] hover:text-white/70 hover:border-[#555] transition-colors"
>
Keep Local
</button>
</div>
</div>
);
}
function DiffItemView({ diff }: { diff: DiffItem }) {
if (diff.type === "added") {
return (
<div className="border border-green-500/30 bg-green-500/5 p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-green-400 text-xs font-mono font-bold">+ ADDED</span>
<span className="text-[#555] text-xs font-mono">
[{getElementTypeLabel(diff.remoteElement!)}]
</span>
</div>
<div className="font-mono text-sm text-green-300/80 break-words">
{truncateText(getElementText(diff.remoteElement!), 150)}
</div>
</div>
);
}
if (diff.type === "removed") {
return (
<div className="border border-red-500/30 bg-red-500/5 p-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-red-400 text-xs font-mono font-bold">- REMOVED</span>
<span className="text-[#555] text-xs font-mono">
[{getElementTypeLabel(diff.localElement!)}]
</span>
</div>
<div className="font-mono text-sm text-red-300/80 break-words line-through">
{truncateText(getElementText(diff.localElement!), 150)}
</div>
</div>
);
}
if (diff.type === "modified") {
return (
<div className="border border-yellow-500/30 bg-yellow-500/5 p-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-yellow-400 text-xs font-mono font-bold">~ MODIFIED</span>
<span className="text-[#555] text-xs font-mono">
[{getElementTypeLabel(diff.localElement!)}]
</span>
</div>
{diff.wordDiff ? (
<div className="font-mono text-xs break-words leading-relaxed">
{diff.wordDiff.map((word, idx) => {
if (word.type === "same") {
return (
<span key={idx} className="text-white/60">
{word.text}
</span>
);
} else if (word.type === "removed") {
return (
<span
key={idx}
className="bg-red-500/30 text-red-300 line-through"
>
{word.text}
</span>
);
} else {
return (
<span key={idx} className="bg-green-500/30 text-green-300">
{word.text}
</span>
);
}
})}
</div>
) : (
<div className="space-y-2">
<div>
<div className="text-[#555] text-[10px] font-mono uppercase mb-1">Your version:</div>
<div className="font-mono text-xs text-red-300/70 break-words bg-red-500/10 p-2 border-l-2 border-red-500/50">
{getElementText(diff.localElement!)}
</div>
</div>
<div>
<div className="text-[#555] text-[10px] font-mono uppercase mb-1">Remote version:</div>
<div className="font-mono text-xs text-green-300/70 break-words bg-green-500/10 p-2 border-l-2 border-green-500/50">
{getElementText(diff.remoteElement!)}
</div>
</div>
</div>
)}
</div>
);
}
return null;
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "...";
}