/** * — Document history slide-out panel. * * Activity feed shows Automerge change history with author attribution, * grouped by author within time windows. Time Machine tab provides a * scrubber over the change timeline to view document state at any point. */ import * as Automerge from "@automerge/automerge"; import { parseChangeMessage, type ParsedChangeMessage } from "../local-first/change-message"; // ── Types ── export interface HistoryEntry { hash: string; actor: string; username: string; time: number; message: string; opsCount: number; seq: number; } export interface GroupedEntry { username: string; entries: HistoryEntry[]; timeRange: { start: number; end: number }; summary: string; } interface TimeGroup { label: string; groups: GroupedEntry[]; } // ── Component ── export class RStackHistoryPanel extends HTMLElement { private _open = false; private _tab: "activity" | "timemachine" = "activity"; private _doc: Automerge.Doc | null = null; private _entries: HistoryEntry[] = []; private _visibleCount = 50; private _timeMachineIndex = -1; private _timeMachineSnapshot: any = null; private _docChangeHandler: (() => void) | null = null; static define() { if (!customElements.get("rstack-history-panel")) { customElements.define("rstack-history-panel", RStackHistoryPanel); } } connectedCallback() { if (!this.shadowRoot) this.attachShadow({ mode: "open" }); this._render(); } open() { this._open = true; this._refreshHistory(); this._render(); // Toggle active state on button document.getElementById("history-btn")?.classList.add("active"); } close() { this._open = false; this._timeMachineSnapshot = null; this._render(); document.getElementById("history-btn")?.classList.remove("active"); } toggle() { if (this._open) this.close(); else this.open(); } setDoc(doc: Automerge.Doc) { this._doc = doc; if (this._open) { this._refreshHistory(); this._render(); } } // ── History extraction ── private _refreshHistory() { if (!this._doc) { this._entries = []; return; } try { const changes = Automerge.getHistory(this._doc); this._entries = changes.map((entry, i) => { const change = entry.change; const parsed = parseChangeMessage(change.message || null); return { hash: change.hash || `change-${i}`, actor: change.actor || "unknown", username: parsed.user || "Unknown", time: parsed.ts || (change.time ? change.time * 1000 : 0), message: parsed.text, opsCount: change.ops?.length || 0, seq: change.seq || i, }; }); } catch (e) { console.warn("[HistoryPanel] Failed to extract history:", e); this._entries = []; } } // ── Grouping ── private _groupEntries(): TimeGroup[] { const entries = this._entries.slice(-this._visibleCount).reverse(); if (entries.length === 0) return []; // Group by same author within 5-minute windows const grouped: GroupedEntry[] = []; let current: GroupedEntry | null = null; const WINDOW = 5 * 60 * 1000; for (const entry of entries) { if ( current && current.username === entry.username && entry.time - current.timeRange.end < WINDOW ) { current.entries.push(entry); current.timeRange.start = Math.min(current.timeRange.start, entry.time); current.timeRange.end = Math.max(current.timeRange.end, entry.time); } else { current = { username: entry.username, entries: [entry], timeRange: { start: entry.time, end: entry.time }, summary: "", }; grouped.push(current); } } // Build summaries for (const g of grouped) { g.summary = this._buildSummary(g.entries); } // Nest into time groups const now = Date.now(); const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); const yesterdayStart = new Date(todayStart); yesterdayStart.setDate(yesterdayStart.getDate() - 1); const weekStart = new Date(todayStart); weekStart.setDate(weekStart.getDate() - 7); const timeGroups: TimeGroup[] = []; const buckets: Record = {}; for (const g of grouped) { const t = g.timeRange.end; let label: string; if (t >= todayStart.getTime()) label = "Today"; else if (t >= yesterdayStart.getTime()) label = "Yesterday"; else if (t >= weekStart.getTime()) label = "This Week"; else label = "Older"; if (!buckets[label]) buckets[label] = []; buckets[label].push(g); } for (const label of ["Today", "Yesterday", "This Week", "Older"]) { if (buckets[label]?.length) { timeGroups.push({ label, groups: buckets[label] }); } } return timeGroups; } private _buildSummary(entries: HistoryEntry[]): string { const actions: Record = {}; for (const e of entries) { const msg = e.message.toLowerCase(); if (msg.startsWith("update")) actions["edited"] = (actions["edited"] || 0) + 1; else if (msg.startsWith("add")) actions["added"] = (actions["added"] || 0) + 1; else if (msg.startsWith("delete") || msg.startsWith("remove")) actions["removed"] = (actions["removed"] || 0) + 1; else if (msg.startsWith("forget")) actions["forgot"] = (actions["forgot"] || 0) + 1; else if (msg.startsWith("remember")) actions["remembered"] = (actions["remembered"] || 0) + 1; else actions["changed"] = (actions["changed"] || 0) + 1; } return Object.entries(actions) .map(([action, count]) => `${action} ${count} item${count > 1 ? "s" : ""}`) .join(", "); } // ── Time Machine ── private _onScrub(index: number) { if (!this._doc || this._entries.length === 0) return; this._timeMachineIndex = index; try { const changes = Automerge.getHistory(this._doc); if (index >= 0 && index < changes.length) { // Get heads at this point in history const hash = changes[index].change.hash; if (hash) { this._timeMachineSnapshot = Automerge.view(this._doc, [hash]); } } } catch (e) { console.warn("[HistoryPanel] Time machine error:", e); } this._renderTimeMachineDetails(); } // ── Rendering ── private _render() { if (!this.shadowRoot) return; if (!this._open) { this.shadowRoot.innerHTML = ""; return; } const timeGroups = this._groupEntries(); const activityHTML = timeGroups.length === 0 ? '
No history available yet. Make some changes to see them here.
' : timeGroups.map(tg => `
${tg.label}
${tg.groups.map(g => `
${this._initial(g.username)}
${this._esc(g.username)} ${this._relativeTime(g.timeRange.end)}
${this._esc(g.summary)}
${g.entries.slice(0, 5).map(e => `
${this._esc(e.message)} ${e.opsCount} op${e.opsCount !== 1 ? "s" : ""}
`).join("")} ${g.entries.length > 5 ? `
+${g.entries.length - 5} more
` : ""}
`).join("")}
`).join(""); const totalChanges = this._entries.length; const scrubberMax = Math.max(0, totalChanges - 1); const scrubberVal = this._timeMachineIndex >= 0 ? this._timeMachineIndex : scrubberMax; const timeMachineHTML = totalChanges === 0 ? '
No history to scrub. Make some changes first.
' : `
Drag the slider to view document state at any point in time.
`; this.shadowRoot.innerHTML = `

History

${this._tab === "activity" ? activityHTML : timeMachineHTML}
${this._entries.length > this._visibleCount && this._tab === "activity" ? `
` : ""}
`; this._bindEvents(); } private _renderTimeMachineDetails() { const details = this.shadowRoot?.getElementById("tm-details"); if (!details) return; const entry = this._entries[this._timeMachineIndex]; if (!entry) { details.innerHTML = '
Select a point in history.
'; return; } const parsed = parseChangeMessage(entry.message); // Show snapshot info const shapeCount = this._timeMachineSnapshot?.shapes ? Object.keys(this._timeMachineSnapshot.shapes).length : 0; details.innerHTML = `
Time ${entry.time ? new Date(entry.time).toLocaleString() : "Unknown"}
Author ${this._esc(entry.username)}
Message ${this._esc(entry.message)}
Operations ${entry.opsCount}
${this._timeMachineSnapshot ? `
Shapes at this point ${shapeCount}
` : ""}
`; // Update the label const label = this.shadowRoot?.querySelector(".time-machine__label"); if (label) label.textContent = `Change ${this._timeMachineIndex + 1} of ${this._entries.length}`; } private _bindEvents() { const sr = this.shadowRoot!; sr.getElementById("close-btn")?.addEventListener("click", () => this.close()); sr.getElementById("overlay")?.addEventListener("click", () => this.close()); // Tab switching sr.querySelectorAll(".tab").forEach(btn => { btn.addEventListener("click", () => { this._tab = (btn as HTMLElement).dataset.tab as "activity" | "timemachine"; this._render(); }); }); // Load more sr.getElementById("load-more-btn")?.addEventListener("click", () => { this._visibleCount += 50; this._render(); }); // Time machine scrubber const scrubber = sr.getElementById("tm-scrubber") as HTMLInputElement; if (scrubber) { scrubber.addEventListener("input", () => { this._onScrub(parseInt(scrubber.value, 10)); }); } } // ── Helpers ── private _initial(name: string): string { return (name.charAt(0) || "?").toUpperCase(); } private _relativeTime(ts: number): string { if (!ts) return ""; const diff = Date.now() - ts; if (diff < 60_000) return "just now"; if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`; if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`; return new Date(ts).toLocaleDateString(); } private _esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } } // ── CSS ── const PANEL_CSS = ` :host { display: contents; } .overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); z-index: 200000; animation: fadeIn 0.2s ease; } .panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 92vw); background: var(--rs-bg-surface, #1e293b); border-left: 1px solid var(--rs-border, #334155); z-index: 200001; display: flex; flex-direction: column; animation: slideIn 0.25s ease; color: var(--rs-text-primary, #e2e8f0); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } .panel-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--rs-btn-secondary-bg, #334155); flex-shrink: 0; } .panel-header h2 { margin: 0; font-size: 1.1rem; font-weight: 600; background: linear-gradient(135deg, #14b8a6, #22d3ee); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .close-btn { background: none; border: none; color: var(--rs-text-muted, #64748b); font-size: 1.5rem; cursor: pointer; padding: 4px 8px; border-radius: 4px; line-height: 1; } .close-btn:hover { color: var(--rs-text-primary); background: var(--rs-btn-secondary-bg); } /* Tabs */ .tabs { display: flex; border-bottom: 1px solid var(--rs-btn-secondary-bg, #334155); flex-shrink: 0; } .tab { flex: 1; padding: 10px 16px; background: none; border: none; border-bottom: 2px solid transparent; color: var(--rs-text-secondary, #94a3b8); font-size: 0.82rem; font-weight: 600; cursor: pointer; transition: color 0.15s, border-color 0.15s; } .tab:hover { color: var(--rs-text-primary); } .tab.active { color: #14b8a6; border-bottom-color: #14b8a6; } .panel-content { flex: 1; overflow-y: auto; padding: 0; } .empty-state { padding: 32px 20px; text-align: center; color: var(--rs-text-muted, #64748b); font-size: 0.85rem; line-height: 1.5; } /* Time groups */ .time-group { padding: 0; } .time-group__label { position: sticky; top: 0; padding: 8px 20px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--rs-text-muted, #64748b); background: var(--rs-bg-surface, #1e293b); border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); z-index: 1; } /* Activity groups */ .activity-group { padding: 12px 20px; border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); transition: background 0.15s; } .activity-group:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.03)); } .activity-group__header { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; } .activity-group__avatar { width: 28px; height: 28px; border-radius: 50%; background: rgba(20,184,166,0.15); color: #14b8a6; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.75rem; flex-shrink: 0; } .activity-group__info { display: flex; align-items: baseline; gap: 8px; flex: 1; min-width: 0; } .activity-group__user { font-size: 0.82rem; font-weight: 600; color: var(--rs-text-primary); } .activity-group__time { font-size: 0.7rem; color: var(--rs-text-muted, #64748b); } .activity-group__summary { font-size: 0.78rem; color: var(--rs-text-secondary, #94a3b8); margin-bottom: 6px; padding-left: 38px; } .activity-group__details { padding-left: 38px; } .activity-entry { display: flex; align-items: center; justify-content: space-between; padding: 2px 0; font-size: 0.72rem; color: var(--rs-text-muted, #64748b); } .activity-entry__msg { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .activity-entry__ops { flex-shrink: 0; margin-left: 8px; font-size: 0.65rem; color: var(--rs-text-muted); opacity: 0.7; } .activity-entry--more { color: #14b8a6; font-style: italic; } /* Time Machine */ .time-machine { padding: 20px; } .time-machine__scrubber { margin-bottom: 20px; } .time-machine__label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--rs-text-secondary); margin-bottom: 8px; } .time-machine__range { width: 100%; accent-color: #14b8a6; cursor: pointer; } .time-machine__details { min-height: 100px; } .tm-info { display: flex; flex-direction: column; gap: 8px; } .tm-info__row { display: flex; align-items: flex-start; gap: 12px; } .tm-info__label { flex: 0 0 90px; font-size: 0.72rem; font-weight: 600; color: var(--rs-text-muted, #64748b); text-transform: uppercase; letter-spacing: 0.04em; } .tm-info__value { flex: 1; font-size: 0.82rem; color: var(--rs-text-primary); word-break: break-word; } /* Load more */ .load-more { padding: 12px 20px; border-top: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.05)); flex-shrink: 0; } .load-more__btn { width: 100%; padding: 8px; background: rgba(20,184,166,0.1); border: 1px solid rgba(20,184,166,0.2); color: #14b8a6; border-radius: 8px; font-size: 0.78rem; font-weight: 600; cursor: pointer; transition: background 0.15s; } .load-more__btn:hover { background: rgba(20,184,166,0.2); } `;