summaryrefslogblamecommitdiff
path: root/makima/frontend/src/components/files/UpdateNotification.tsx
blob: 863f951ad4eeef86f5bc40de854601bdc4302886 (plain) (tree)
1
2
3
4
5
6
7
8
9
10

                                                 

                                       

                            



                        






























                                                              





























































                                                                            





                                                       
                        




















                                                                                       

                                                                





                                
                   








                                

                                    

             



                                                                          

                                                   

          














                                                                                                                                        


                



























                                                                                                                                                 


          










































                                                                                         

























                                                                             
                












                                                                                                                                  

                  
          










                                                                
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) + "...";
}