feat(history): humanize activity entries with icons and timestamps

Replace raw Automerge change messages (e.g. "Update shape abc-123-uuid")
with human-readable text and contextual icons. Add per-entry timestamps
for clearer chronology within author groups.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-23 17:56:13 -07:00
parent 5775e810d6
commit 1282ba5325
1 changed files with 67 additions and 18 deletions

View File

@ -216,16 +216,49 @@ export class RStackHistoryPanel extends HTMLElement {
const actions: Record<string, number> = {}; const actions: Record<string, number> = {};
for (const e of entries) { for (const e of entries) {
const msg = e.message.toLowerCase(); const msg = e.message.toLowerCase();
if (msg.startsWith("update")) actions["edited"] = (actions["edited"] || 0) + 1; if (msg.startsWith("update shape")) actions["moved/edited shapes"] = (actions["moved/edited shapes"] || 0) + 1;
else if (msg.startsWith("add")) actions["added"] = (actions["added"] || 0) + 1; else if (msg.startsWith("update layer")) actions["updated layers"] = (actions["updated layers"] || 0) + 1;
else if (msg.startsWith("update")) actions["updated items"] = (actions["updated items"] || 0) + 1;
else if (msg.startsWith("add shape") || 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("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("forget")) actions["forgot"] = (actions["forgot"] || 0) + 1;
else if (msg.startsWith("remember")) actions["remembered"] = (actions["remembered"] || 0) + 1; else if (msg.startsWith("remember")) actions["restored"] = (actions["restored"] || 0) + 1;
else if (msg.startsWith("undo")) actions["undid"] = (actions["undid"] || 0) + 1;
else if (msg.startsWith("redo")) actions["redid"] = (actions["redid"] || 0) + 1;
else actions["changed"] = (actions["changed"] || 0) + 1; else actions["changed"] = (actions["changed"] || 0) + 1;
} }
return Object.entries(actions) return Object.entries(actions)
.map(([action, count]) => `${action} ${count} item${count > 1 ? "s" : ""}`) .map(([action, count]) => `${action}: ${count}`)
.join(", "); .join(" · ");
}
/** Turn raw Automerge change messages into human-readable text with icons. */
private _humanizeMessage(msg: string): { icon: string; text: string } {
// Strip shape/layer/flow UUIDs for readability
const short = msg.replace(/\s+[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, "")
.replace(/\s+[a-z]+-[a-z0-9]{6,}/gi, ""); // short IDs like folk-abc123
const m = msg.toLowerCase();
if (m.startsWith("update shape")) return { icon: "✏️", text: "Edited shape" };
if (m.startsWith("add shape")) return { icon: "", text: "Added shape" };
if (m.startsWith("delete shape")) return { icon: "🗑", text: "Deleted shape" };
if (m.startsWith("forget shape")) return { icon: "👻", text: "Forgot shape" };
if (m.startsWith("remember shape")) return { icon: "💡", text: "Restored shape" };
if (m.startsWith("undo delete")) return { icon: "↩️", text: "Undid delete" };
if (m.startsWith("undo")) return { icon: "↩️", text: "Undo" };
if (m.startsWith("redo")) return { icon: "↪️", text: "Redo" };
if (m.startsWith("add layer")) return { icon: "📑", text: "Added layer" };
if (m.startsWith("remove layer")) return { icon: "📑", text: "Removed layer" };
if (m.startsWith("update layer")) return { icon: "📑", text: "Updated layer" };
if (m.startsWith("add flow")) return { icon: "🔀", text: "Added flow" };
if (m.startsWith("remove flow")) return { icon: "🔀", text: "Removed flow" };
if (m.startsWith("add connection")) return { icon: "🔗", text: "Added connection" };
if (m.startsWith("remove connection")) return { icon: "🔗", text: "Removed connection" };
if (m.startsWith("event:")) return { icon: "⚡", text: short.trim() };
if (m.startsWith("subscribe")) return { icon: "📡", text: "Subscribed shape" };
if (m.startsWith("set view mode")) return { icon: "👁", text: short.trim() };
if (m.startsWith("initialize")) return { icon: "🏗", text: "Initialized space" };
return { icon: "·", text: short.trim() || msg };
} }
// ── Time Machine ── // ── Time Machine ──
@ -278,13 +311,17 @@ export class RStackHistoryPanel extends HTMLElement {
</div> </div>
<div class="activity-group__summary">${this._esc(g.summary)}</div> <div class="activity-group__summary">${this._esc(g.summary)}</div>
<div class="activity-group__details"> <div class="activity-group__details">
${g.entries.slice(0, 5).map(e => ` ${g.entries.slice(0, 8).map(e => {
const h = this._humanizeMessage(e.message);
const t = e.time ? this._shortTime(e.time) : "";
return `
<div class="activity-entry"> <div class="activity-entry">
<span class="activity-entry__msg">${this._esc(e.message)}</span> <span class="activity-entry__icon">${h.icon}</span>
<span class="activity-entry__ops">${e.opsCount} op${e.opsCount !== 1 ? "s" : ""}</span> <span class="activity-entry__msg">${this._esc(h.text)}</span>
</div> <span class="activity-entry__time">${t}</span>
`).join("")} </div>`;
${g.entries.length > 5 ? `<div class="activity-entry activity-entry--more">+${g.entries.length - 5} more</div>` : ""} }).join("")}
${g.entries.length > 8 ? `<div class="activity-entry activity-entry--more">+${g.entries.length - 8} more</div>` : ""}
</div> </div>
</div> </div>
`).join("")} `).join("")}
@ -426,6 +463,12 @@ export class RStackHistoryPanel extends HTMLElement {
return new Date(ts).toLocaleDateString(); return new Date(ts).toLocaleDateString();
} }
private _shortTime(ts: number): string {
if (!ts) return "";
const d = new Date(ts);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
private _esc(s: string): string { private _esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
} }
@ -612,10 +655,17 @@ const PANEL_CSS = `
.activity-entry { .activity-entry {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 6px;
padding: 2px 0; padding: 3px 0;
font-size: 0.72rem; font-size: 0.75rem;
color: var(--rs-text-muted, #64748b); color: var(--rs-text-secondary, #94a3b8);
}
.activity-entry__icon {
flex-shrink: 0;
width: 18px;
text-align: center;
font-size: 0.7rem;
} }
.activity-entry__msg { .activity-entry__msg {
@ -626,11 +676,10 @@ const PANEL_CSS = `
white-space: nowrap; white-space: nowrap;
} }
.activity-entry__ops { .activity-entry__time {
flex-shrink: 0; flex-shrink: 0;
margin-left: 8px;
font-size: 0.65rem; font-size: 0.65rem;
color: var(--rs-text-muted); color: var(--rs-text-muted, #64748b);
opacity: 0.7; opacity: 0.7;
} }