diff options
Diffstat (limited to 'claude_sesiones/template.html')
| -rw-r--r-- | claude_sesiones/template.html | 1022 |
1 files changed, 1022 insertions, 0 deletions
diff --git a/claude_sesiones/template.html b/claude_sesiones/template.html new file mode 100644 index 0000000..279749c --- /dev/null +++ b/claude_sesiones/template.html @@ -0,0 +1,1022 @@ +<!doctype html> +<html lang="es"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<meta name="color-scheme" content="light dark"> +<meta name="robots" content="noindex, nofollow"> +<title>Sesiones de Claude Code</title> + +<script> + // Corre antes del primer pintado para que el tema elegido no parpadee: + // primero la preferencia guardada, si no la del sistema. + (function () { + var saved = null; + try { saved = localStorage.getItem('cs-tema'); } catch (e) {} + var dark = saved ? saved === 'dark' + : matchMedia('(prefers-color-scheme: dark)').matches; + document.documentElement.dataset.theme = dark ? 'dark' : 'light'; + })(); +</script> + +<style> + /* Una paleta por tema y nada más: el data-theme lo pone el script de arriba + antes de pintar, así que no hace falta repetir los valores dentro de una + @media. El botón del toolbar cambia ese mismo atributo. */ + + :root, :root[data-theme="light"] { + --ground: #EDEEF2; + --surface: #FFFFFF; + --raised: #F6F7F9; + --ink: #14161C; + --ink-soft: #3D4250; + --muted: #6A6F7E; + --faint: #9AA0AE; + --line: #DCDEE5; + --line-soft:#E7E9EE; + --amber: #A85A16; + --amber-lo: #C98A47; + --amber-bg: rgba(168, 90, 22, 0.09); + --scrim: rgba(20, 22, 28, .38); + --shadow: 0 1px 2px rgba(20, 22, 28, .06), 0 8px 24px -12px rgba(20, 22, 28, .18); + --shadow-lg:0 24px 70px -20px rgba(20, 22, 28, .45); + --focus: #2C6FD1; + + --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --mono: ui-monospace, "JetBrains Mono", "SFMono-Regular", "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; + } + + :root[data-theme="dark"] { + --ground: #0E1015; + --surface: #171A21; + --raised: #1D212A; + --ink: #DFE2EA; + --ink-soft: #B4BAC7; + --muted: #878DA0; + --faint: #666C7D; + --line: #262A34; + --line-soft:#1F232C; + --amber: #E29A4A; + --amber-lo: #8A6435; + --amber-bg: rgba(226, 154, 74, 0.11); + --scrim: rgba(0, 0, 0, .62); + --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px -12px rgba(0,0,0,.7); + --shadow-lg:0 24px 70px -20px rgba(0,0,0,.85); + --focus: #6BA5F0; + } + + * { box-sizing: border-box; } + + body { + margin: 0; + background: var(--ground); + color: var(--ink); + font-family: var(--sans); + font-size: 15px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + } + body.locked { overflow: hidden; } + + .wrap { max-width: 1180px; margin: 0 auto; padding: 40px 24px 96px; } + + /* ---------- masthead ---------- */ + + .masthead { display: flex; flex-direction: column; gap: 8px; margin-bottom: 28px; } + + .eyebrow { + font-family: var(--mono); + font-size: 11px; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--amber); + } + + h1 { + margin: 0; + font-size: clamp(28px, 4.5vw, 40px); + line-height: 1.1; + letter-spacing: -.022em; + font-weight: 620; + text-wrap: balance; + } + + .lede { margin: 0; max-width: 64ch; color: var(--muted); font-size: 15px; } + .lede code, .foot code { + font-family: var(--mono); + font-size: .88em; + background: var(--amber-bg); + color: var(--amber); + padding: 1px 5px; + border-radius: 4px; + } + + /* ---------- stats ---------- */ + + .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); + gap: 1px; + background: var(--line); + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; + margin-bottom: 24px; + } + .stat { background: var(--surface); padding: 14px 16px 16px; } + .stat-k { + display: block; + font-family: var(--mono); + font-size: 10px; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--faint); + margin-bottom: 5px; + } + .stat-v { + font-family: var(--mono); + font-size: 22px; + font-variant-numeric: tabular-nums; + letter-spacing: -.02em; + color: var(--ink); + } + .stat-v small { font-size: 12px; color: var(--muted); letter-spacing: 0; } + + /* ---------- toolbar ---------- */ + + .toolbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + padding: 12px 0; + background: linear-gradient(var(--ground) 76%, transparent); + } + + .field { position: relative; flex: 1 1 240px; min-width: 180px; } + .field svg { position: absolute; left: 11px; top: 50%; transform: translateY(-50%); color: var(--faint); pointer-events: none; } + + input[type="search"], select { + width: 100%; + font: inherit; + font-size: 14px; + color: var(--ink); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 12px; + appearance: none; + } + input[type="search"] { padding-left: 34px; font-family: var(--mono); font-size: 13px; } + input[type="search"]::-webkit-search-cancel-button { filter: grayscale(1) opacity(.5); } + + select { + flex: 0 1 auto; + width: auto; + max-width: 300px; + padding-right: 30px; + font-family: var(--mono); + font-size: 12.5px; + background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%); + background-position: calc(100% - 16px) calc(50% + 1px), calc(100% - 11px) calc(50% + 1px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + } + + .toggle { + display: inline-flex; + align-items: center; + gap: 7px; + font-family: var(--mono); + font-size: 12px; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 12px; + cursor: pointer; + user-select: none; + white-space: nowrap; + } + .toggle input { accent-color: var(--amber); margin: 0; } + .toggle:hover { color: var(--ink); } + .toggle:has(input:checked) { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); } + + .tbtn { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--muted); + background: var(--surface); + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 11px; + cursor: pointer; + flex-shrink: 0; + } + .tbtn:hover { color: var(--amber); border-color: var(--amber-lo); } + .tbtn .sun { display: none; } + :root[data-theme="dark"] .tbtn .sun { display: block; } + :root[data-theme="dark"] .tbtn .moon { display: none; } + + .noscript { + margin: 0 0 24px; + padding: 14px 16px; + border: 1px solid var(--amber-lo); + background: var(--amber-bg); + border-radius: 10px; + color: var(--ink); + font-size: 14px; + } + + :is(input, select, button, .toggle, .row[tabindex]):focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; + } + + /* ---------- tabla ---------- */ + + .grid { border: 1px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--surface); box-shadow: var(--shadow); } + + .row { + display: grid; + grid-template-columns: 3px 1fr 108px 58px 66px 128px; + align-items: center; + gap: 0 14px; + border-bottom: 1px solid var(--line-soft); + } + .row:last-child { border-bottom: 0; } + .row.clickable { cursor: pointer; } + .row.clickable:hover { background: var(--raised); } + + .head { + position: sticky; + top: 62px; + z-index: 10; + background: var(--raised); + border-bottom: 1px solid var(--line); + } + .head span, .head button { + font-family: var(--mono); + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; + color: var(--faint); + padding: 9px 0; + background: none; + border: 0; + text-align: left; + cursor: default; + } + .head button { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; } + .head button:hover { color: var(--ink); } + .head button[aria-sort] { color: var(--amber); } + .head button .car { opacity: 0; } + .head button[aria-sort] .car { opacity: 1; } + .head .num { justify-content: flex-end; text-align: right; } + .head .pad-l { padding-left: 14px; } + .head .pad-r { padding-right: 16px; } + + .r-body { padding: 11px 0 12px; min-width: 0; } + + .stripe { align-self: stretch; background: var(--stripe, transparent); } + + .title { + margin: 0 0 3px; + font-size: 14px; + line-height: 1.42; + color: var(--ink); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + mark { background: var(--amber-bg); color: var(--amber); border-radius: 3px; padding: 0 1px; } + + .meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 8px; + font-family: var(--mono); + font-size: 11.5px; + color: var(--muted); + min-width: 0; + } + .path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; } + + .chip { + font-size: 10px; + letter-spacing: .06em; + text-transform: uppercase; + padding: 1px 5px; + border-radius: 3px; + border: 1px solid var(--line); + color: var(--faint); + white-space: nowrap; + } + .chip-branch { color: var(--muted); } + .chip-empty, .chip-auto { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); } + + .snippet { + margin: 5px 0 0; + font-family: var(--mono); + font-size: 11.5px; + line-height: 1.5; + color: var(--muted); + border-left: 2px solid var(--amber-lo); + padding-left: 9px; + } + + .cell-n { + font-family: var(--mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--ink-soft); + text-align: right; + padding: 11px 0 12px; + } + .cell-date { font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--ink-soft); padding: 11px 0 12px; } + .cell-date small { display: block; color: var(--faint); font-size: 10.5px; letter-spacing: .04em; } + .dim { color: var(--faint); } + + .cell-act { padding: 9px 16px 9px 0; display: flex; justify-content: flex-end; } + + .copy { + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + background: var(--raised); + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 9px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; + transition: color .12s, border-color .12s, background .12s; + } + .copy:hover, .copy.done { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); } + + .empty-row .title { color: var(--muted); font-style: italic; } + + .none { padding: 48px 20px; text-align: center; color: var(--muted); font-family: var(--mono); font-size: 13px; } + .count { font-family: var(--mono); font-size: 11.5px; color: var(--faint); padding: 10px 2px 0; letter-spacing: .04em; } + + /* ---------- lector ---------- */ + + .scrim { + position: fixed; + inset: 0; + background: var(--scrim); + opacity: 0; + pointer-events: none; + transition: opacity .2s; + z-index: 40; + } + .scrim.open { opacity: 1; pointer-events: auto; } + + .reader { + position: fixed; + top: 0; + right: 0; + height: 100%; + width: min(920px, 100%); + display: flex; + flex-direction: column; + background: var(--ground); + border-left: 1px solid var(--line); + box-shadow: var(--shadow-lg); + transform: translateX(100%); + transition: transform .24s cubic-bezier(.4, 0, .2, 1); + z-index: 41; + visibility: hidden; + } + .reader.open { transform: none; visibility: visible; } + + .reader-head { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 18px 20px 15px; + border-bottom: 1px solid var(--line); + background: var(--surface); + } + .reader-head h2 { + margin: 0 0 6px; + font-size: 17px; + line-height: 1.3; + letter-spacing: -.012em; + font-weight: 600; + text-wrap: balance; + } + .rh-text { flex: 1; min-width: 0; } + .rh-acts { display: flex; gap: 7px; flex-shrink: 0; } + + .xbtn { + background: var(--raised); + border: 1px solid var(--line); + border-radius: 6px; + color: var(--muted); + cursor: pointer; + padding: 7px 9px; + display: inline-flex; + align-items: center; + transition: color .12s, border-color .12s, background .12s; + } + .xbtn:hover { color: var(--ink); } + .xbtn.done { color: var(--amber); border-color: var(--amber-lo); background: var(--amber-bg); } + + .transcript { + flex: 1; + overflow-y: auto; + overscroll-behavior: contain; + padding: 20px 20px 64px; + display: flex; + flex-direction: column; + gap: 14px; + } + + .turn { display: flex; flex-direction: column; gap: 5px; } + .who { + font-family: var(--mono); + font-size: 10px; + letter-spacing: .13em; + text-transform: uppercase; + color: var(--faint); + } + .turn-u .who { color: var(--amber); } + + .bubble { + border-radius: 8px; + padding: 11px 14px; + font-size: 14.5px; + line-height: 1.62; + white-space: pre-wrap; + overflow-wrap: anywhere; + border: 1px solid var(--line-soft); + background: var(--surface); + color: var(--ink-soft); + } + .turn-u .bubble { background: var(--amber-bg); border-color: var(--amber-lo); color: var(--ink); } + + .bubble pre { + margin: 9px 0; + padding: 10px 12px; + background: var(--ground); + border: 1px solid var(--line); + border-radius: 6px; + overflow-x: auto; + font-family: var(--mono); + font-size: 12.5px; + line-height: 1.55; + white-space: pre; + color: var(--ink); + } + .bubble code { + font-family: var(--mono); + font-size: .87em; + background: var(--raised); + border: 1px solid var(--line-soft); + border-radius: 4px; + padding: 1px 4px; + } + .bubble strong { color: var(--ink); font-weight: 600; } + .bubble .h { + display: block; + margin: 4px 0 -2px; + font-size: .95em; + font-weight: 620; + letter-spacing: -.008em; + color: var(--ink); + } + .bubble pre code { background: none; border: 0; padding: 0; font-size: inherit; } + + .tool { + font-family: var(--mono); + font-size: 11.5px; + color: var(--muted); + display: flex; + align-items: baseline; + gap: 8px; + padding: 3px 0 3px 12px; + border-left: 2px solid var(--line); + overflow-wrap: anywhere; + } + .tool b { color: var(--amber); font-weight: 500; flex-shrink: 0; } + + .reader-empty { color: var(--muted); font-family: var(--mono); font-size: 13px; text-align: center; padding: 60px 20px; } + + /* ---------- toast ---------- */ + + .toast { + position: fixed; + left: 50%; + bottom: 26px; + transform: translate(-50%, 14px); + background: var(--ink); + color: var(--ground); + font-family: var(--mono); + font-size: 12px; + padding: 9px 14px; + border-radius: 7px; + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + transition: opacity .16s, transform .16s; + z-index: 60; + max-width: min(90vw, 560px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .toast.show { opacity: 1; transform: translate(-50%, 0); } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + align-items: center; + margin-top: 18px; + font-family: var(--mono); + font-size: 11px; + color: var(--faint); + } + .legend i { display: inline-block; width: 16px; height: 3px; border-radius: 2px; margin-right: 6px; vertical-align: middle; } + + .foot { + margin-top: 26px; + padding-top: 18px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 13px; + max-width: 68ch; + } + .foot p { margin: 0 0 8px; } + + @media (max-width: 860px) { + .wrap { padding: 28px 14px 72px; } + .row { grid-template-columns: 3px 1fr auto; gap: 0 12px; } + .head { display: none; } + .r-body { padding: 12px 0 4px; grid-column: 2 / -1; } + .cell-date { grid-column: 2; padding: 0 0 10px; } + .cell-n { display: none; } + .cell-act { grid-column: 3; grid-row: 2; padding: 0 12px 10px 0; } + select { max-width: 100%; flex: 1 1 100%; } + .reader { width: 100%; border-left: 0; } + } + + @media (prefers-reduced-motion: reduce) { + * { transition: none !important; } + } +</style> +</head> + +<body> +<div class="wrap"> + <header class="masthead"> + <span class="eyebrow">~/.claude/projects</span> + <h1>Tus sesiones de Claude Code</h1> + <p class="lede">Cada conversación queda en un <code>.jsonl</code> local. Hacé clic en cualquier fila para leerla, o copiá el comando para retomarla — el <code>--resume</code> solo encuentra la sesión desde su directorio original, así que el botón copia el <code>cd</code> también.</p> + </header> + + <noscript> + <p class="noscript">Esta página arma la lista con JavaScript. Con JavaScript + desactivado no se ve nada — los datos igual están adentro del archivo, en el + <code><script id="payload"></code>.</p> + </noscript> + + <div class="stats" id="stats"></div> + + <div class="toolbar"> + <div class="field"> + <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5 14 14"/></svg> + <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> + <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> + </button> + </div> + + <div class="grid" role="table" aria-label="Sesiones de Claude Code"> + <div class="row head" 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> + <button type="button" data-sort="u" class="num">Msgs <span class="car">▾</span></button> + <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 id="rows"></div> + </div> + + <p class="count" id="count"></p> + + <div class="legend"> + <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> + <span><i style="background:var(--amber);opacity:.26"></i>dos semanas</span> + <span><i style="background:var(--line)"></i>más viejo</span> + </div> + + <div class="foot"> + <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> + <p id="snap"></p> + </div> +</div> + +<div class="scrim" id="scrim"></div> + +<aside class="reader" id="reader" role="dialog" aria-modal="true" aria-labelledby="rTitle" tabindex="-1"> + <div class="reader-head"> + <div class="rh-text"> + <h2 id="rTitle"></h2> + <div class="meta" id="rMeta"></div> + </div> + <div class="rh-acts"> + <button type="button" class="xbtn" id="rCopy" title="Copiar comando para reanudar" aria-label="Copiar comando para reanudar"> + <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5"/><path d="M10.5 3.5H3a.5.5 0 0 0-.5.5v8"/></svg> + </button> + <button type="button" class="xbtn" id="rClose" title="Cerrar (Esc)" aria-label="Cerrar"> + <svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="m4 4 8 8M12 4l-8 8"/></svg> + </button> + </div> + </div> + <div class="transcript" id="rBody"></div> +</aside> + +<div class="toast" id="toast" role="status" aria-live="polite"></div> + +<script id="payload" type="application/json">__DATA__</script> +<script> +const DATA = JSON.parse(document.getElementById('payload').textContent); +// 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 BY_ID = new Map(DATA.map(d => [d.id, d])); + +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'); + +let sortKey = 'l', sortDir = -1, openId = null, lastFocus = null; + +/* ---------- formato ---------- */ + +const DAY = 864e5; +const MES = ['ene','feb','mar','abr','may','jun','jul','ago','sep','oct','nov','dic']; +const pad = n => String(n).padStart(2, '0'); + +const daysAgo = iso => (NOW - new Date(iso)) / DAY; +const fmtDate = iso => { const d = new Date(iso); return `${pad(d.getDate())} ${MES[d.getMonth()]}`; }; +const fmtTime = iso => { const d = new Date(iso); return `${pad(d.getHours())}:${pad(d.getMinutes())}`; }; + +function fmtRel(iso) { + const n = daysAgo(iso); + if (n < 1) return 'hoy'; + if (n < 2) return 'ayer'; + if (n < 7) return `hace ${Math.floor(n)} d`; + if (n < 30) return `hace ${Math.floor(n / 7)} sem`; + return `hace ${Math.floor(n / 30)} mes`; +} +function fmtDur(m) { + if (m == null) return '—'; + if (m < 1) return '<1m'; + if (m < 60) return m + 'm'; + const h = Math.floor(m / 60), r = m % 60; + return r ? `${h}h${pad(r)}` : `${h}h`; +} +function stripeFor(iso) { + const n = daysAgo(iso); + if (n < 2) return 'var(--amber)'; + if (n < 7) return 'color-mix(in srgb, var(--amber) 55%, transparent)'; + if (n < 14) return 'color-mix(in srgb, var(--amber) 26%, transparent)'; + return 'var(--line)'; +} + +const esc = s => String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + +// Resalta la primera coincidencia. La aguja se escapa igual que el pajar: si +// no, buscar "&" o "<" nunca encontraría nada, porque en `s` ya son entidades. +function mark(text, q) { + const s = esc(text); + if (!q) return s; + const needle = esc(q); + const i = s.toLowerCase().indexOf(needle.toLowerCase()); + if (i < 0) return s; + return s.slice(0, i) + '<mark>' + s.slice(i, i + needle.length) + '</mark>' + + s.slice(i + needle.length); +} + +// 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) { + const stash = []; + // Tras esc() no queda ningún "<" crudo en el texto: todos son <. Por eso un + // marcador con "<" no puede colisionar con el contenido del mensaje. + const keep = html => `<K${stash.push(html) - 1}>`; + + let out = esc(text); + out = out.replace(/```[a-zA-Z0-9_+-]*\n?([\s\S]*?)```/g, + (_, code) => keep(`<pre>${code.replace(/\n+$/, '')}</pre>`)); + 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>'); + + return out.replace(/<K(\d+)>/g, (_, i) => stash[i]); +} + +/* ---------- cabecera ---------- */ + +(function stats() { + const msgs = DATA.reduce((a, d) => a + d.u, 0); + const mb = DATA.reduce((a, d) => a + d.k, 0) / 1024; + const ds = DATA.map(d => new Date(d.l)).sort((a, b) => a - b); + const f = ds[0], l = ds[ds.length - 1]; + $('#stats').innerHTML = [ + ['sesiones', DATA.length, ''], + ['proyectos', new Set(DATA.map(d => d.p)).size, ''], + ['tus mensajes', msgs, ''], + ['en disco', mb.toFixed(1), ' MB'], + ['período', `${pad(f.getDate())} ${MES[f.getMonth()]} – ${pad(l.getDate())} ${MES[l.getMonth()]}`, ''], + ].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() { + const counts = {}; + DATA.forEach(d => counts[d.p] = (counts[d.p] || 0) + 1); + projEl.innerHTML = `<option value="">todos los proyectos (${DATA.length})</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(''); +})(); + +/* ---------- lista ---------- */ + +function convoText(d) { + if (d._flat === undefined) d._flat = d.c.map(m => m.x).join('\n').toLowerCase(); + return d._flat; +} + +function snippetFor(d, q) { + for (const m of d.c) { + const i = m.x.toLowerCase().indexOf(q); + if (i < 0) continue; + const from = Math.max(0, i - 55); + const raw = m.x.slice(from, i + q.length + 75).replace(/\s+/g, ' '); + return (from ? '…' : '') + raw + '…'; + } + return null; +} + +function current() { + const q = qEl.value.trim().toLowerCase(); + const proj = projEl.value; + const deep = deepEl.checked; + + const list = DATA.filter(d => { + if (proj && d.p !== proj) return false; + if (emptyEl.checked && d.e) return false; + if (!q) return true; + if ((d.t || '').toLowerCase().includes(q) || d.p.toLowerCase().includes(q) + || d.id.toLowerCase().includes(q) || (d.b || '').toLowerCase().includes(q)) return true; + return deep && convoText(d).includes(q); + }); + + const k = sortKey; + list.sort((a, b) => { + if (k === 't') { + const av = (a.t || '~').toLowerCase(), bv = (b.t || '~').toLowerCase(); + return av < bv ? -sortDir : av > bv ? sortDir : 0; + } + const av = k === 'l' ? new Date(a.l) : (a[k] ?? -1); + const bv = k === 'l' ? new Date(b.l) : (b[k] ?? -1); + return (av - bv) * sortDir; + }); + return list; +} + +function render() { + const list = current(); + const q = qEl.value.trim(); + const ql = q.toLowerCase(); + + if (!list.length) { + rowsEl.innerHTML = `<div class="none">Ninguna sesión coincide con ese filtro.</div>`; + countEl.textContent = ''; + return; + } + + rowsEl.innerHTML = list.map(d => { + const inMeta = q && ((d.t || '').toLowerCase().includes(ql) || d.p.toLowerCase().includes(ql)); + const snip = q && deepEl.checked && !inMeta ? snippetFor(d, ql) : null; + const chips = [ + d.e ? '<span class="chip chip-empty">vacía</span>' : '', + d.n ? '<span class="chip chip-auto">no interactiva</span>' : '', + d.b && d.b !== 'HEAD' ? `<span class="chip chip-branch">${esc(d.b)}</span>` : '', + d.i ? '<span class="chip" title="Ruta deducida de otras sesiones del mismo proyecto">ruta inferida</span>' : '', + ].join(''); + + return ` + <div class="row${d.e ? ' empty-row' : ' clickable'}" role="row" data-id="${d.id}"${d.e ? '' : ' tabindex="0"'}> + <div class="stripe" style="--stripe:${stripeFor(d.l)}"></div> + <div class="r-body"> + <p class="title">${d.t ? mark(d.t, q) : 'sesión abierta sin mensajes'}</p> + <div class="meta"><span class="path" title="${esc(d.p)}">${mark(d.p, q)}</span>${chips}</div> + ${snip ? `<p class="snippet">${mark(snip, q)}</p>` : ''} + </div> + <div class="cell-date">${fmtDate(d.l)}<small>${fmtRel(d.l)} · ${fmtTime(d.l)}</small></div> + <div class="cell-n${d.u ? '' : ' dim'}">${d.u || '—'}</div> + <div class="cell-n${d.d ? '' : ' dim'}">${fmtDur(d.d)}</div> + <div class="cell-act"> + <button type="button" class="copy" data-copy="${d.id}" title="cd ${esc(d.p)} && claude --resume ${d.id}"> + <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="5.5" y="5.5" width="8" height="9" rx="1.5"/><path d="M10.5 3.5H3a.5.5 0 0 0-.5.5v8"/></svg> + <span>${d.id.slice(0, 8)}</span> + </button> + </div> + </div>`; + }).join(''); + + countEl.textContent = list.length === DATA.length + ? `${DATA.length} sesiones · clic en una fila para leerla` + : `${list.length} de ${DATA.length} sesiones`; +} + +/* ---------- lector ---------- */ + +const WHO = { u: 'vos', a: 'claude' }; + +function openReader(id) { + const d = BY_ID.get(id); + if (!d || d.e) return; + openId = id; + lastFocus = document.activeElement; + + $('#rTitle').textContent = d.t || 'Sesión sin título'; + $('#rMeta').innerHTML = [ + `<span class="path">${esc(d.p)}</span>`, + `<span>${fmtDate(d.l)} · ${fmtTime(d.l)}</span>`, + `<span>${d.u} tuyos / ${d.a} de Claude</span>`, + `<span>${fmtDur(d.d)}</span>`, + `<span class="chip">${d.id.slice(0, 8)}</span>`, + ].join(''); + + bodyEl.innerHTML = d.c.length + ? d.c.map(m => m.r === 't' + ? `<div class="tool"><b>⚒</b><span>${esc(m.x)}</span></div>` + : `<div class="turn turn-${m.r}"><span class="who">${WHO[m.r]}</span><div class="bubble">${renderBody(m.x)}</div></div>` + ).join('') + : `<p class="reader-empty">Esta sesión no tiene mensajes.</p>`; + + if (location.hash.slice(1) !== id) history.replaceState(null, '', '#' + id); + + bodyEl.scrollTop = 0; + scrimEl.classList.add('open'); + readerEl.classList.add('open'); + document.body.classList.add('locked'); + readerEl.focus(); +} + +function closeReader() { + if (!openId) return; + openId = null; + if (location.hash) history.replaceState(null, '', location.pathname + location.search); + scrimEl.classList.remove('open'); + readerEl.classList.remove('open'); + document.body.classList.remove('locked'); + if (lastFocus && document.contains(lastFocus)) lastFocus.focus(); +} + +function openFromHash() { + const id = decodeURIComponent(location.hash.slice(1)); + if (id && BY_ID.has(id)) openReader(id); +} + +// El diálogo es aria-modal: el foco tiene que quedar adentro mientras está abierto. +readerEl.addEventListener('keydown', e => { + if (e.key !== 'Tab') return; + const f = [...readerEl.querySelectorAll('button, [tabindex]:not([tabindex="-1"])')] + .filter(el => el.offsetParent !== null); + if (!f.length) return; + const first = f[0], last = f[f.length - 1]; + if (e.shiftKey && (document.activeElement === first || document.activeElement === readerEl)) { + e.preventDefault(); last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first.focus(); + } +}); + +/* ---------- copiar ---------- */ + +const cmdFor = d => `cd ${d.p} && claude --resume ${d.id}`; + +function copyText(text) { + if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text); + return new Promise((res, rej) => { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy') ? res() : rej(); } + catch (e) { rej(e); } + finally { ta.remove(); } + }); +} + +let toastTimer; +function toast(msg) { + toastEl.textContent = msg; + toastEl.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => toastEl.classList.remove('show'), 2600); +} + +function copyFor(d, btn) { + if (!d) return; + copyText(cmdFor(d)).then(() => { + if (btn) { btn.classList.add('done'); setTimeout(() => btn.classList.remove('done'), 1400); } + toast('Copiado: ' + cmdFor(d)); + }).catch(() => toast('No se pudo copiar — el comando está en el tooltip')); +} + +/* ---------- eventos ---------- */ + +rowsEl.addEventListener('click', e => { + const copyBtn = e.target.closest('[data-copy]'); + if (copyBtn) { + e.stopPropagation(); + copyFor(BY_ID.get(copyBtn.dataset.copy), copyBtn); + return; + } + const row = e.target.closest('.row.clickable'); + if (row) openReader(row.dataset.id); +}); + +rowsEl.addEventListener('keydown', e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + const row = e.target.closest('.row.clickable'); + if (!row || e.target.closest('[data-copy]')) return; + e.preventDefault(); + openReader(row.dataset.id); +}); + +$('#rClose').addEventListener('click', closeReader); +scrimEl.addEventListener('click', closeReader); +$('#rCopy').addEventListener('click', e => copyFor(BY_ID.get(openId), e.currentTarget)); + +document.addEventListener('keydown', e => { + if (e.key === 'Escape') { + if (openId) closeReader(); + else if (document.activeElement === qEl && qEl.value) { qEl.value = ''; render(); } + return; + } + if (openId) return; + const typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement.tagName); + if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) { + e.preventDefault(); + qEl.focus(); + qEl.select(); + } +}); + +$('#theme').addEventListener('click', () => { + const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'; + document.documentElement.dataset.theme = next; + try { localStorage.setItem('cs-tema', next); } catch (e) {} +}); + +document.querySelectorAll('[data-sort]').forEach(btn => { + btn.addEventListener('click', () => { + const k = btn.dataset.sort; + if (sortKey === k) sortDir = -sortDir; + else { sortKey = k; sortDir = k === 't' ? 1 : -1; } + document.querySelectorAll('[data-sort]').forEach(b => b.removeAttribute('aria-sort')); + btn.setAttribute('aria-sort', sortDir === 1 ? 'ascending' : 'descending'); + btn.querySelector('.car').textContent = sortDir === 1 ? '▴' : '▾'; + render(); + |