aboutsummaryrefslogtreecommitdiffstats
path: root/claude_sesiones/template.html
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-08-17 15:12:12 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-08-17 15:12:12 -0300
commit0d1fe286ac6ea30b91c15b44c0872b57a864ea7f (patch)
tree554d6c8a0bdcf1c30bfa2d752d557b39c1554d6e /claude_sesiones/template.html
parentbd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f (diff)
downloadclaude-logbook-0d1fe286ac6ea30b91c15b44c0872b57a864ea7f.tar.gz
claude-logbook-0d1fe286ac6ea30b91c15b44c0872b57a864ea7f.zip
Explorar también las memorias de los proyectos
Claude Code deja, además de las sesiones, un directorio memory/ por proyecto: un .md por recuerdo con frontmatter, y un MEMORY.md que hace de índice. Lo que no figura en ese índice sigue en disco pero deja de cargarse en contexto, así que la diferencia entre ambos es justamente lo que vale la pena mirar. -m cambia de sesiones a memorias y reusa los mismos verbos: claude-sesiones -m tabla de memorias claude-sesiones -m docker busca en nombre, descripción y cuerpo claude-sesiones -m -s 3 lee la nº 3 (o -s <nombre>) claude-sesiones -m --check audita índices, enlaces y orígenes claude-sesiones -m -D 3 la borra y la saca de MEMORY.md claude-sesiones -m --type feedback filtra por tipo declarado --check es la parte con criterio propio: reporta memorias que no están en MEMORY.md, entradas del índice que apuntan a archivos que ya no existen, enlaces [[...]] rotos y sesiones de origen que ya no están. El registro de una memoria usa claves cortas como el de sesiones, por el mismo motivo: viaja embebido en el HTML, que ahora tiene su propia pestaña de memorias. Claude-Session: https://claude.ai/code/session_01RmtZ9qBemrc9TncwVTG6ED
Diffstat (limited to 'claude_sesiones/template.html')
-rw-r--r--claude_sesiones/template.html434
1 files changed, 403 insertions, 31 deletions
diff --git a/claude_sesiones/template.html b/claude_sesiones/template.html
index 279749c..e56cc30 100644
--- a/claude_sesiones/template.html
+++ b/claude_sesiones/template.html
@@ -570,6 +570,153 @@
.reader { width: 100%; border-left: 0; }
}
+ /* ---------- pestañas y memorias ---------- */
+
+ .tabs {
+ display: flex;
+ gap: 2px;
+ margin-bottom: 18px;
+ border-bottom: 1px solid var(--line);
+ }
+ .tab {
+ font: inherit;
+ font-size: 13.5px;
+ color: var(--muted);
+ background: none;
+ border: 0;
+ border-bottom: 2px solid transparent;
+ padding: 8px 14px;
+ margin-bottom: -1px;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ }
+ .tab:hover { color: var(--ink); }
+ .tab[aria-selected="true"] { color: var(--amber); border-bottom-color: var(--amber); }
+ .tab .n {
+ font-family: var(--mono);
+ font-size: 11px;
+ font-variant-numeric: tabular-nums;
+ color: var(--faint);
+ background: var(--raised);
+ border: 1px solid var(--line);
+ border-radius: 10px;
+ padding: 0 6px;
+ }
+ .tab[aria-selected="true"] .n { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ body[data-mode="s"] .only-m,
+ body[data-mode="m"] .only-s { display: none; }
+
+ .row.mem { grid-template-columns: 3px 1fr 104px 210px 108px; }
+
+ .mem-name { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
+ .mem-name .nm { font-size: 14px; color: var(--ink); font-family: var(--mono); }
+ .mem-desc {
+ margin: 3px 0 0;
+ font-size: 12.5px;
+ line-height: 1.45;
+ color: var(--muted);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ }
+
+ .ty {
+ font-family: var(--mono);
+ font-size: 10.5px;
+ letter-spacing: .05em;
+ padding: 1px 6px;
+ border-radius: 3px;
+ border: 1px solid var(--line);
+ color: var(--muted);
+ white-space: nowrap;
+ }
+ .ty-project { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+ .ty-user { color: var(--focus); border-color: var(--focus); background: transparent; }
+ .ty-feedback { color: var(--copper, var(--amber)); border-color: var(--amber-lo); }
+ .ty-reference { color: var(--muted); }
+
+ /* Una memoria fuera de MEMORY.md sigue en disco pero no se carga en contexto. */
+ .chip-unlisted { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ .mem-body {
+ font-size: 14.5px;
+ line-height: 1.68;
+ color: var(--ink-soft);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ }
+ .mem-body pre {
+ margin: 10px 0;
+ padding: 10px 12px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ overflow-x: auto;
+ font-family: var(--mono);
+ font-size: 12.5px;
+ white-space: pre;
+ color: var(--ink);
+ }
+ .mem-body code {
+ font-family: var(--mono);
+ font-size: .87em;
+ background: var(--surface);
+ border: 1px solid var(--line-soft);
+ border-radius: 4px;
+ padding: 1px 4px;
+ }
+ .mem-body pre code { background: none; border: 0; padding: 0; font-size: inherit; }
+ .mem-body strong { color: var(--ink); font-weight: 600; }
+ .mem-body .h { display: block; margin: 10px 0 -2px; font-weight: 620; color: var(--ink); }
+
+ .wl {
+ font-family: var(--mono);
+ font-size: .9em;
+ color: var(--amber);
+ background: var(--amber-bg);
+ border: 1px solid var(--amber-lo);
+ border-radius: 4px;
+ padding: 0 4px;
+ cursor: pointer;
+ }
+ .wl.dead { color: var(--faint); border-color: var(--line); background: none; cursor: default; }
+
+ .mem-foot {
+ margin-top: 22px;
+ padding-top: 14px;
+ border-top: 1px solid var(--line);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--muted);
+ }
+ .mem-foot .lbl { color: var(--faint); margin-right: 6px; }
+
+ .jump {
+ align-self: flex-start;
+ font-family: var(--mono);
+ font-size: 11.5px;
+ color: var(--muted);
+ background: var(--raised);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ padding: 6px 10px;
+ cursor: pointer;
+ }
+ .jump:hover { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); }
+
+ @media (max-width: 860px) {
+ .row.mem { grid-template-columns: 3px 1fr auto; }
+ .row.mem .cell-ty { grid-column: 2; padding-bottom: 10px; }
+ .row.mem .cell-proj { display: none; }
+ }
+
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}
@@ -590,6 +737,15 @@
<code>&lt;script id="payload"&gt;</code>.</p>
</noscript>
+ <div class="tabs" role="tablist" aria-label="Qué mirar">
+ <button type="button" class="tab" role="tab" data-mode="s" aria-selected="true">
+ Sesiones <span class="n" id="nS"></span>
+ </button>
+ <button type="button" class="tab" role="tab" data-mode="m" aria-selected="false">
+ Memorias <span class="n" id="nM"></span>
+ </button>
+ </div>
+
<div class="stats" id="stats"></div>
<div class="toolbar">
@@ -598,8 +754,10 @@
<input type="search" id="q" placeholder="buscar título, ruta o uuid… (/)" autocomplete="off" spellcheck="false" aria-label="Buscar sesiones">
</div>
<select id="proj" aria-label="Filtrar por proyecto"></select>
- <label class="toggle"><input type="checkbox" id="deep"> buscar dentro del chat</label>
- <label class="toggle"><input type="checkbox" id="hideEmpty"> ocultar vacías</label>
+ <label class="toggle only-s"><input type="checkbox" id="deep"> buscar dentro del chat</label>
+ <label class="toggle only-s"><input type="checkbox" id="hideEmpty"> ocultar vacías</label>
+ <select id="mtype" class="only-m" aria-label="Filtrar por tipo de memoria"></select>
+ <label class="toggle only-m"><input type="checkbox" id="unlisted"> solo fuera del índice</label>
<button type="button" class="tbtn" id="theme" title="Cambiar entre claro y oscuro" aria-label="Cambiar tema">
<svg class="moon" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><path d="M13.5 9.6A5.8 5.8 0 0 1 6.4 2.5a5.8 5.8 0 1 0 7.1 7.1Z"/></svg>
<svg class="sun" width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="8" cy="8" r="3.1"/><path d="M8 1v1.6M8 13.4V15M1 8h1.6M13.4 8H15M3.1 3.1l1.1 1.1M11.8 11.8l1.1 1.1M12.9 3.1l-1.1 1.1M4.2 11.8l-1.1 1.1"/></svg>
@@ -607,7 +765,7 @@
</div>
<div class="grid" role="table" aria-label="Sesiones de Claude Code">
- <div class="row head" role="row">
+ <div class="row head only-s" role="row">
<span></span>
<button type="button" data-sort="t" class="pad-l">Sesión <span class="car">▾</span></button>
<button type="button" data-sort="l">Fecha <span class="car">▾</span></button>
@@ -615,12 +773,19 @@
<button type="button" data-sort="d" class="num">Dur. <span class="car">▾</span></button>
<span class="pad-r" style="text-align:right">Reanudar</span>
</div>
+ <div class="row head mem only-m" role="row">
+ <span></span>
+ <button type="button" data-msort="name" class="pad-l">Memoria <span class="car">▾</span></button>
+ <button type="button" data-msort="ty">Tipo <span class="car">▾</span></button>
+ <button type="button" data-msort="p">Proyecto <span class="car">▾</span></button>
+ <button type="button" data-msort="l">Fecha <span class="car">▾</span></button>
+ </div>
<div id="rows"></div>
</div>
<p class="count" id="count"></p>
- <div class="legend">
+ <div class="legend only-s">
<span>Franja izquierda = antigüedad:</span>
<span><i style="background:var(--amber)"></i>hoy / ayer</span>
<span><i style="background:var(--amber);opacity:.55"></i>última semana</span>
@@ -628,7 +793,16 @@
<span><i style="background:var(--line)"></i>más viejo</span>
</div>
- <div class="foot">
+ <div class="legend only-m">
+ <span><span class="chip chip-unlisted">fuera del índice</span> = está en disco pero no figura en MEMORY.md, así que no se carga en contexto</span>
+ </div>
+
+ <div class="foot only-m">
+ <p>Cada proyecto guarda sus recuerdos en <code>&lt;proyecto&gt;/memory/</code>: un <code>.md</code> por memoria y un <code>MEMORY.md</code> que los indexa. El índice es lo único que se lee al arrancar una sesión.</p>
+ <p>Los tipos los define Claude al escribirlas: <b>project</b> es trabajo en curso, <b>user</b> quién sos y cómo trabajás, <b>feedback</b> correcciones tuyas, <b>reference</b> punteros a recursos externos.</p>
+ </div>
+
+ <div class="foot only-s">
<p>Los títulos los genera Claude durante la sesión. Cuando falta, cae al primer mensaje tuyo — y ahí se nota, porque arranca en minúscula o suena a pregunta suelta.</p>
<p><b>Vacía</b> es una sesión que se abrió pero nunca recibió un mensaje: un <code>/resume</code> cancelado, un <code>/login</code>. <b>No interactiva</b> es un <code>claude -p</code> con algo piped por stdin — típicamente un <code>git diff</code> para redactar el mensaje de commit, no un chat.</p>
<p>La transcripción trae el texto de la conversación y una línea por herramienta usada, pero no lo que esas herramientas devolvieron: son el 95 % del volumen en disco.</p>
@@ -660,18 +834,30 @@
<script id="payload" type="application/json">__DATA__</script>
<script>
-const DATA = JSON.parse(document.getElementById('payload').textContent);
+const PAYLOAD = JSON.parse(document.getElementById('payload').textContent);
+const DATA = PAYLOAD.s || [];
+const MEM = PAYLOAD.m || [];
// Instantánea = la actividad más reciente de los datos, para que "hoy" siga siendo
// cierto respecto de cuándo se leyeron los logs y no del reloj de quien mira.
-const NOW = new Date(Math.max(...DATA.map(d => +new Date(d.l))));
+const NOW = new Date(Math.max(
+ ...DATA.map(d => +new Date(d.l)), ...MEM.map(m => +new Date(m.l))));
const BY_ID = new Map(DATA.map(d => [d.id, d]));
+const BY_NAME = new Map(MEM.map(m => [m.name, m]));
+// Los enlaces [[...]] pueden apuntar al nombre declarado o al del archivo.
+MEM.forEach(m => { const f = m.file.replace(/\.md$/, ''); if (!BY_NAME.has(f)) BY_NAME.set(f, m); });
const $ = s => document.querySelector(s);
const rowsEl = $('#rows'), qEl = $('#q'), projEl = $('#proj'), deepEl = $('#deep'),
emptyEl = $('#hideEmpty'), countEl = $('#count'), toastEl = $('#toast'),
- readerEl = $('#reader'), scrimEl = $('#scrim'), bodyEl = $('#rBody');
+ readerEl = $('#reader'), scrimEl = $('#scrim'), bodyEl = $('#rBody'),
+ typeEl = $('#mtype'), unlistedEl = $('#unlisted');
-let sortKey = 'l', sortDir = -1, openId = null, lastFocus = null;
+let sortKey = 'l', sortDir = -1, lastFocus = null;
+let mSortKey = 'l', mSortDir = -1;
+// Qué hay abierto en el lector: {kind: 's'|'m', key} o null.
+let open_ = null;
+// Sin memorias la pestaña no aporta nada, así que arrancamos siempre en sesiones.
+let mode = 's';
/* ---------- formato ---------- */
@@ -723,7 +909,7 @@ function mark(text, q) {
// Markdown mínimo: código, negritas y títulos. Los saltos los resuelve pre-wrap.
// El código se guarda aparte antes de tocar negritas, para que un ** dentro de
// un bloque de código quede tal cual.
-function renderBody(text) {
+function renderBody(text, wikilinks) {
const stash = [];
// Tras esc() no queda ningún "<" crudo en el texto: todos son &lt;. Por eso un
// marcador con "<" no puede colisionar con el contenido del mensaje.
@@ -735,6 +921,16 @@ function renderBody(text) {
out = out.replace(/`([^`\n]+)`/g, (_, code) => keep(`<code>${code}</code>`));
out = out.replace(/^#{1,6}\s+(.+)$/gm, '<b class="h">$1</b>');
out = out.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
+ if (wikilinks) {
+ // Va acá adentro, con el código ya guardado aparte: un [[...]] dentro de un
+ // bloque de código tiene que quedar como texto.
+ out = out.replace(/\[\[([^\]\n]+)\]\]/g, (_, name) => {
+ const hit = BY_NAME.get(name);
+ return hit
+ ? `<button type="button" class="wl" data-wl="${esc(hit.name)}">${esc(name)}</button>`
+ : `<span class="wl dead" title="todavía no existe una memoria con ese nombre">${esc(name)}</span>`;
+ });
+ }
return out.replace(/<K(\d+)>/g, (_, i) => stash[i]);
}
@@ -752,17 +948,31 @@ function renderBody(text) {
['tus mensajes', msgs, ''],
['en disco', mb.toFixed(1), ' MB'],
['período', `${pad(f.getDate())} ${MES[f.getMonth()]} – ${pad(l.getDate())} ${MES[l.getMonth()]}`, ''],
+ ['memorias', MEM.length, ''],
].map(([k, v, s]) => `<div class="stat"><span class="stat-k">${k}</span><span class="stat-v">${v}${s ? `<small>${s}</small>` : ''}</span></div>`).join('');
})();
-(function projects() {
+function fillProjects() {
+ const src = mode === 'm' ? MEM : DATA;
const counts = {};
- DATA.forEach(d => counts[d.p] = (counts[d.p] || 0) + 1);
- projEl.innerHTML = `<option value="">todos los proyectos (${DATA.length})</option>` +
+ src.forEach(d => counts[d.p] = (counts[d.p] || 0) + 1);
+ const label = mode === 'm' ? 'memorias' : 'sesiones';
+ projEl.innerHTML = `<option value="">todos los proyectos (${src.length} ${label})</option>` +
Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.map(([p, n]) => `<option value="${esc(p)}">${esc(p)} · ${n}</option>`).join('');
+}
+
+(function types() {
+ const counts = {};
+ MEM.forEach(m => counts[m.ty] = (counts[m.ty] || 0) + 1);
+ typeEl.innerHTML = `<option value="">todos los tipos (${MEM.length})</option>` +
+ Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .map(([t, n]) => `<option value="${esc(t)}">${esc(t)} · ${n}</option>`).join('');
})();
+$('#nS').textContent = DATA.length;
+$('#nM').textContent = MEM.length;
+
/* ---------- lista ---------- */
function convoText(d) {
@@ -808,7 +1018,7 @@ function current() {
return list;
}
-function render() {
+function renderSessions() {
const list = current();
const q = qEl.value.trim();
const ql = q.toLowerCase();
@@ -854,15 +1064,99 @@ function render() {
: `${list.length} de ${DATA.length} sesiones`;
}
+/* ---------- memorias ---------- */
+
+function currentMem() {
+ const q = qEl.value.trim().toLowerCase();
+ const proj = projEl.value, ty = typeEl.value;
+
+ const list = MEM.filter(m => {
+ if (proj && m.p !== proj) return false;
+ if (ty && m.ty !== ty) return false;
+ if (unlistedEl.checked && m.ix) return false;
+ if (!q) return true;
+ return m.name.toLowerCase().includes(q)
+ || m.desc.toLowerCase().includes(q)
+ || m.p.toLowerCase().includes(q)
+ || m.body.toLowerCase().includes(q);
+ });
+
+ list.sort((a, b) => {
+ const k = mSortKey;
+ if (k === 'l') return (new Date(a.l) - new Date(b.l)) * mSortDir;
+ const av = String(a[k]).toLowerCase(), bv = String(b[k]).toLowerCase();
+ return av < bv ? -mSortDir : av > bv ? mSortDir : 0;
+ });
+ return list;
+}
+
+function renderMem() {
+ const list = currentMem();
+ const q = qEl.value.trim();
+
+ if (!list.length) {
+ rowsEl.innerHTML = `<div class="none">Ninguna memoria coincide con ese filtro.</div>`;
+ countEl.textContent = '';
+ return;
+ }
+
+ rowsEl.innerHTML = list.map(m => `
+ <div class="row mem clickable" role="row" data-mem="${esc(m.name)}" tabindex="0">
+ <div class="stripe" style="--stripe:${stripeFor(m.l)}"></div>
+ <div class="r-body">
+ <div class="mem-name">
+ <span class="nm">${mark(m.name, q)}</span>
+ ${m.ix ? '' : '<span class="chip chip-unlisted">fuera del índice</span>'}
+ </div>
+ ${m.desc ? `<p class="mem-desc">${mark(m.desc, q)}</p>` : ''}
+ </div>
+ <div class="cell-ty"><span class="ty ty-${esc(m.ty)}">${esc(m.ty)}</span></div>
+ <div class="cell-proj meta"><span class="path" title="${esc(m.p)}">${mark(m.p, q)}</span></div>
+ <div class="cell-date">${fmtDate(m.l)}<small>${fmtRel(m.l)}</small></div>
+ </div>`).join('');
+
+ countEl.textContent = list.length === MEM.length
+ ? `${MEM.length} memorias · clic en una para leerla`
+ : `${list.length} de ${MEM.length} memorias`;
+}
+
+function render() {
+ return mode === 'm' ? renderMem() : renderSessions();
+}
+
+function setMode(next) {
+ if (next === mode) return;
+ mode = next;
+ document.body.dataset.mode = mode;
+ document.querySelectorAll('.tab').forEach(t =>
+ t.setAttribute('aria-selected', String(t.dataset.mode === mode)));
+ qEl.placeholder = mode === 'm'
+ ? 'buscar nombre, descripción o contenido… (/)'
+ : 'buscar título, ruta o uuid… (/)';
+ // Los filtros son de otro conjunto: arrastrarlos entre pestañas confunde.
+ projEl.value = '';
+ fillProjects();
+ render();
+}
+
/* ---------- lector ---------- */
const WHO = { u: 'vos', a: 'claude' };
-function openReader(id) {
+function showReader() {
+ bodyEl.scrollTop = 0;
+ scrimEl.classList.add('open');
+ readerEl.classList.add('open');
+ document.body.classList.add('locked');
+ readerEl.focus();
+}
+
+function openChat(id) {
const d = BY_ID.get(id);
if (!d || d.e) return;
- openId = id;
+ open_ = { kind: 's', key: id };
lastFocus = document.activeElement;
+ $('#rCopy').hidden = false;
$('#rTitle').textContent = d.t || 'Sesión sin título';
$('#rMeta').innerHTML = [
@@ -881,17 +1175,54 @@ function openReader(id) {
: `<p class="reader-empty">Esta sesión no tiene mensajes.</p>`;
if (location.hash.slice(1) !== id) history.replaceState(null, '', '#' + id);
+ showReader();
+}
- bodyEl.scrollTop = 0;
- scrimEl.classList.add('open');
- readerEl.classList.add('open');
- document.body.classList.add('locked');
- readerEl.focus();
+function openMemory(name) {
+ const m = BY_NAME.get(name);
+ if (!m) return;
+ open_ = { kind: 'm', key: m.name };
+ lastFocus = document.activeElement;
+ // El botón de copiar reanuda una sesión: en una memoria no aplica.
+ $('#rCopy').hidden = true;
+
+ $('#rTitle').textContent = m.name;
+ $('#rMeta').innerHTML = [
+ `<span class="ty ty-${esc(m.ty)}">${esc(m.ty)}</span>`,
+ `<span class="path">${esc(m.p)}</span>`,
+ `<span>${fmtDate(m.l)} · ${fmtTime(m.l)}</span>`,
+ `<span>${m.k} KB</span>`,
+ m.ix ? '' : '<span class="chip chip-unlisted">fuera del índice</span>',
+ ].filter(Boolean).join('');
+
+ const origen = BY_ID.get(m.src);
+ const pie = [];
+ if (m.desc) pie.push(`<div><span class="lbl">descripción</span>${esc(m.desc)}</div>`);
+ pie.push(`<div><span class="lbl">archivo</span>${esc(m.file)}</div>`);
+ if (!m.ix) {
+ pie.push(`<div>${m.hix
+ ? 'No figura en MEMORY.md: sigue en disco, pero no se carga en contexto.'
+ : 'Este proyecto no tiene MEMORY.md, así que no se carga ninguna de sus memorias.'}</div>`);
+ }
+ if (m.src) {
+ pie.push(origen
+ ? `<button type="button" class="jump" data-chat="${esc(m.src)}">↩ la escribió esta sesión: ${esc(origen.t || m.src.slice(0, 8))}</button>`
+ : `<div><span class="lbl">la escribió</span>la sesión ${esc(m.src.slice(0, 8))}, que ya no está en disco</div>`);
+ }
+
+ bodyEl.innerHTML = `<div class="mem-body">${renderBody(m.body, true)}</div>`
+ + `<div class="mem-foot">${pie.join('')}</div>`;
+
+ const hash = 'm:' + m.name;
+ if (decodeURIComponent(location.hash.slice(1)) !== hash) {
+ history.replaceState(null, '', '#' + encodeURIComponent(hash));
+ }
+ showReader();
}
function closeReader() {
- if (!openId) return;
- openId = null;
+ if (!open_) return;
+ open_ = null;
if (location.hash) history.replaceState(null, '', location.pathname + location.search);
scrimEl.classList.remove('open');
readerEl.classList.remove('open');
@@ -900,8 +1231,14 @@ function closeReader() {
}
function openFromHash() {
- const id = decodeURIComponent(location.hash.slice(1));
- if (id && BY_ID.has(id)) openReader(id);
+ const ref = decodeURIComponent(location.hash.slice(1));
+ if (!ref) return;
+ if (ref.startsWith('m:')) {
+ const name = ref.slice(2);
+ if (BY_NAME.has(name)) { setMode('m'); openMemory(name); }
+ } else if (BY_ID.has(ref)) {
+ openChat(ref);
+ }
}
// El diálogo es aria-modal: el foco tiene que quedar adentro mientras está abierto.
@@ -962,7 +1299,9 @@ rowsEl.addEventListener('click', e => {
return;
}
const row = e.target.closest('.row.clickable');
- if (row) openReader(row.dataset.id);
+ if (!row) return;
+ if (row.dataset.mem) openMemory(row.dataset.mem);
+ else openChat(row.dataset.id);
});
rowsEl.addEventListener('keydown', e => {
@@ -970,20 +1309,31 @@ rowsEl.addEventListener('keydown', e => {
const row = e.target.closest('.row.clickable');
if (!row || e.target.closest('[data-copy]')) return;
e.preventDefault();
- openReader(row.dataset.id);
+ if (row.dataset.mem) openMemory(row.dataset.mem);
+ else openChat(row.dataset.id);
+});
+
+// Dentro del lector: enlaces [[...]] entre memorias y salto a la sesión de origen.
+bodyEl.addEventListener('click', e => {
+ const wl = e.target.closest('[data-wl]');
+ if (wl) { openMemory(wl.dataset.wl); return; }
+ const jump = e.target.closest('[data-chat]');
+ if (jump) { setMode('s'); openChat(jump.dataset.chat); }
});
$('#rClose').addEventListener('click', closeReader);
scrimEl.addEventListener('click', closeReader);
-$('#rCopy').addEventListener('click', e => copyFor(BY_ID.get(openId), e.currentTarget));
+$('#rCopy').addEventListener('click', e => {
+ if (open_ && open_.kind === 's') copyFor(BY_ID.get(open_.key), e.currentTarget);
+});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
- if (openId) closeReader();
+ if (open_) closeReader();
else if (document.activeElement === qEl && qEl.value) { qEl.value = ''; render(); }
return;
}
- if (openId) return;
+ if (open_) return;
const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName);
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) {
e.preventDefault();
@@ -1011,10 +1361,32 @@ document.querySelectorAll('[data-sort]').forEach(btn => {
});
document.querySelector('[data-sort="l"]').setAttribute('aria-sort', 'descending');
+document.querySelectorAll('[data-msort]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const k = btn.dataset.msort;
+ if (mSortKey === k) mSortDir = -mSortDir;
+ else { mSortKey = k; mSortDir = k === 'l' ? -1 : 1; }
+ document.querySelectorAll('[data-msort]').forEach(b => b.removeAttribute('aria-sort'));
+ btn.setAttribute('aria-sort', mSortDir === 1 ? 'ascending' : 'descending');
+ btn.querySelector('.car').textContent = mSortDir === 1 ? '▴' : '▾';
+ render();
+ });
+});
+document.querySelector('[data-msort="l"]').setAttribute('aria-sort', 'descending');
+
+document.querySelectorAll('.tab').forEach(t =>
+ t.addEventListener('click', () => setMode(t.dataset.mode)));
+
+// Sin memorias la pestaña no tiene nada que mostrar.
+if (!MEM.length) document.querySelector('.tab[data-mode="m"]').hidden = true;
+
$('#snap').textContent = `Instantánea leída el ${pad(NOW.getDate())} ${MES[NOW.getMonth()]} ${NOW.getFullYear()} a las ${fmtTime(NOW.toISOString())}. Las fechas relativas son respecto de ese momento; volvé a correr claude-sesiones --html para actualizarla.`;
-[qEl, projEl, deepEl, emptyEl].forEach(el => el.addEventListener(el === qEl ? 'input' : 'change', render));
+[qEl, projEl, deepEl, emptyEl, typeEl, unlistedEl].forEach(
+ el => el.addEventListener(el === qEl ? 'input' : 'change', render));
+document.body.dataset.mode = mode;
+fillProjects();
render();
openFromHash();
</script>