ccakashic 0.2.8 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderMessage = renderMessage;
3
4
  exports.generate = generate;
4
5
  const template_assets_1 = require("./template-assets");
5
- function escapeHtml(str) {
6
- return String(str)
7
- .replace(/&/g, '&')
8
- .replace(/</g, '&lt;')
9
- .replace(/>/g, '&gt;')
10
- .replace(/"/g, '&quot;');
11
- }
6
+ const resume_ui_1 = require("./resume-ui");
7
+ const util_1 = require("./util");
12
8
  function formatTime(ts) {
13
9
  if (!ts)
14
10
  return '';
@@ -32,21 +28,21 @@ function toolUseSummary(msg) {
32
28
  const input = msg.input || {};
33
29
  switch (name) {
34
30
  case 'Bash':
35
- return `Bash: <code>${escapeHtml((input.command || '').slice(0, 120))}</code>`;
31
+ return `Bash: <code>${(0, util_1.escapeHtml)((input.command || '').slice(0, 120))}</code>`;
36
32
  case 'Read':
37
- return `Read: <code>${escapeHtml(input.file_path || '')}</code>`;
33
+ return `Read: <code>${(0, util_1.escapeHtml)(input.file_path || '')}</code>`;
38
34
  case 'Write':
39
- return `Write: <code>${escapeHtml(input.file_path || '')}</code>`;
35
+ return `Write: <code>${(0, util_1.escapeHtml)(input.file_path || '')}</code>`;
40
36
  case 'Edit':
41
- return `Edit: <code>${escapeHtml(input.file_path || '')}</code>`;
37
+ return `Edit: <code>${(0, util_1.escapeHtml)(input.file_path || '')}</code>`;
42
38
  case 'Grep':
43
- return `Grep: <code>${escapeHtml(input.pattern || '')}</code>`;
39
+ return `Grep: <code>${(0, util_1.escapeHtml)(input.pattern || '')}</code>`;
44
40
  case 'Glob':
45
- return `Glob: <code>${escapeHtml(input.pattern || '')}</code>`;
41
+ return `Glob: <code>${(0, util_1.escapeHtml)(input.pattern || '')}</code>`;
46
42
  case 'Agent':
47
- return `Agent: ${escapeHtml(input.description || input.subagent_type || '')}`;
43
+ return `Agent: ${(0, util_1.escapeHtml)(input.description || input.subagent_type || '')}`;
48
44
  default:
49
- return escapeHtml(name);
45
+ return (0, util_1.escapeHtml)(name);
50
46
  }
51
47
  }
52
48
  function renderToolResult(msg) {
@@ -59,30 +55,30 @@ function renderToolResult(msg) {
59
55
  // Bash result
60
56
  if (rich.stdout !== undefined) {
61
57
  if (rich.stdout) {
62
- parts.push(`<div class="tool-output"><pre><code>${escapeHtml(rich.stdout)}</code></pre></div>`);
58
+ parts.push(`<div class="tool-output"><pre><code>${(0, util_1.escapeHtml)(rich.stdout)}</code></pre></div>`);
63
59
  }
64
60
  if (rich.stderr) {
65
- parts.push(`<div class="tool-output stderr"><pre><code>${escapeHtml(rich.stderr)}</code></pre></div>`);
61
+ parts.push(`<div class="tool-output stderr"><pre><code>${(0, util_1.escapeHtml)(rich.stderr)}</code></pre></div>`);
66
62
  }
67
63
  return parts.join('');
68
64
  }
69
65
  // File read result
70
66
  if (rich.type === 'text' && rich.file) {
71
67
  const f = rich.file;
72
- parts.push(`<div class="tool-meta">${escapeHtml(f.filePath)} (lines ${f.startLine}-${f.startLine + f.numLines - 1} of ${f.totalLines})</div>`);
68
+ parts.push(`<div class="tool-meta">${(0, util_1.escapeHtml)(f.filePath)} (lines ${f.startLine}-${f.startLine + f.numLines - 1} of ${f.totalLines})</div>`);
73
69
  if (f.content) {
74
- parts.push(`<div class="tool-output"><pre><code>${escapeHtml(f.content.slice(0, 3000))}</code></pre></div>`);
70
+ parts.push(`<div class="tool-output"><pre><code>${(0, util_1.escapeHtml)(f.content.slice(0, 3000))}</code></pre></div>`);
75
71
  }
76
72
  return parts.join('');
77
73
  }
78
74
  // File edit/create with structuredPatch
79
75
  if ((rich.type === 'update' || rich.type === 'create') && rich.filePath) {
80
- parts.push(`<div class="tool-meta">${escapeHtml(rich.filePath)}</div>`);
76
+ parts.push(`<div class="tool-meta">${(0, util_1.escapeHtml)(rich.filePath)}</div>`);
81
77
  if (rich.structuredPatch && rich.structuredPatch.length > 0) {
82
78
  parts.push(renderDiff(rich.structuredPatch));
83
79
  }
84
80
  else if (rich.content) {
85
- parts.push(`<div class="tool-output"><pre><code>${escapeHtml(rich.content.slice(0, 3000))}</code></pre></div>`);
81
+ parts.push(`<div class="tool-output"><pre><code>${(0, util_1.escapeHtml)(rich.content.slice(0, 3000))}</code></pre></div>`);
86
82
  }
87
83
  return parts.join('');
88
84
  }
@@ -90,7 +86,7 @@ function renderToolResult(msg) {
90
86
  // Fallback: raw content
91
87
  const content = result.fullContent || result.content;
92
88
  if (content) {
93
- parts.push(`<div class="tool-output"><pre><code>${escapeHtml(content.slice(0, 5000))}</code></pre></div>`);
89
+ parts.push(`<div class="tool-output"><pre><code>${(0, util_1.escapeHtml)(content.slice(0, 5000))}</code></pre></div>`);
94
90
  }
95
91
  return parts.join('');
96
92
  }
@@ -103,13 +99,13 @@ function renderDiff(patches) {
103
99
  const ch = line[0];
104
100
  const text = line.slice(1);
105
101
  if (ch === '+') {
106
- lines.push(`<div class="diff-add">+${escapeHtml(text)}</div>`);
102
+ lines.push(`<div class="diff-add">+${(0, util_1.escapeHtml)(text)}</div>`);
107
103
  }
108
104
  else if (ch === '-') {
109
- lines.push(`<div class="diff-del">-${escapeHtml(text)}</div>`);
105
+ lines.push(`<div class="diff-del">-${(0, util_1.escapeHtml)(text)}</div>`);
110
106
  }
111
107
  else {
112
- lines.push(`<div class="diff-ctx"> ${escapeHtml(text)}</div>`);
108
+ lines.push(`<div class="diff-ctx"> ${(0, util_1.escapeHtml)(text)}</div>`);
113
109
  }
114
110
  }
115
111
  }
@@ -155,9 +151,9 @@ function renderMessage(msg) {
155
151
  const itemBadge = makeItemBadge(msg);
156
152
  switch (msg.type) {
157
153
  case 'user':
158
- return `<div class="msg msg-user" id="${id}">${time}<div class="msg-content" data-markdown>${escapeHtml(msg.text)}</div>${turnBadge}</div>`;
154
+ return `<div class="msg msg-user" id="${id}">${time}<div class="msg-content" data-markdown>${(0, util_1.escapeHtml)(msg.text)}</div>${turnBadge}</div>`;
159
155
  case 'assistant':
160
- return `<div class="msg msg-assistant" id="${id}">${time}<div class="msg-content" data-markdown>${escapeHtml(msg.text)}</div>${itemBadge ? `<div>${itemBadge}</div>` : ''}</div>`;
156
+ return `<div class="msg msg-assistant" id="${id}">${time}<div class="msg-content" data-markdown>${(0, util_1.escapeHtml)(msg.text)}</div>${itemBadge ? `<div>${itemBadge}</div>` : ''}</div>`;
161
157
  case 'thinking':
162
158
  return `<div class="msg msg-thinking" id="${id}">${time}<span class="thinking-indicator">Thinking...</span></div>`;
163
159
  case 'tool_use': {
@@ -170,18 +166,18 @@ function renderMessage(msg) {
170
166
  }
171
167
  case 'tool_result':
172
168
  // Unpaired tool result (shouldn't happen often)
173
- return `<div class="msg msg-tool" id="${id}"><div class="tool-output"><pre><code>${escapeHtml((msg.content || '').slice(0, 2000))}</code></pre></div></div>`;
169
+ return `<div class="msg msg-tool" id="${id}"><div class="tool-output"><pre><code>${(0, util_1.escapeHtml)((msg.content || '').slice(0, 2000))}</code></pre></div></div>`;
174
170
  case 'local_command': {
175
- const cmd = msg.command ? `<div class="local-cmd-input"><span class="local-cmd-prompt">$</span> ${escapeHtml(msg.command)}</div>` : '';
176
- const stdout = msg.stdout && msg.stdout.trim() ? `<pre class="local-cmd-output"><code>${escapeHtml(msg.stdout)}</code></pre>` : '';
177
- const stderr = msg.stderr && msg.stderr.trim() ? `<pre class="local-cmd-output local-cmd-stderr"><code>${escapeHtml(msg.stderr)}</code></pre>` : '';
171
+ const cmd = msg.command ? `<div class="local-cmd-input"><span class="local-cmd-prompt">$</span> ${(0, util_1.escapeHtml)(msg.command)}</div>` : '';
172
+ const stdout = msg.stdout && msg.stdout.trim() ? `<pre class="local-cmd-output"><code>${(0, util_1.escapeHtml)(msg.stdout)}</code></pre>` : '';
173
+ const stderr = msg.stderr && msg.stderr.trim() ? `<pre class="local-cmd-output local-cmd-stderr"><code>${(0, util_1.escapeHtml)(msg.stderr)}</code></pre>` : '';
178
174
  return `<div class="msg msg-local-cmd" id="${id}">${time}${cmd}${stdout}${stderr}${turnBadge}</div>`;
179
175
  }
180
176
  case 'system':
181
177
  if (msg.subtype === 'turn_duration') {
182
178
  return ''; // Now shown in turn usage badge
183
179
  }
184
- return `<div class="msg msg-system" id="${id}">${escapeHtml(msg.content || '')}</div>`;
180
+ return `<div class="msg msg-system" id="${id}">${(0, util_1.escapeHtml)(msg.content || '')}</div>`;
185
181
  default:
186
182
  return '';
187
183
  }
@@ -191,21 +187,21 @@ function renderToolInput(msg) {
191
187
  const name = msg.toolName;
192
188
  // Show command for Bash
193
189
  if (name === 'Bash' && input.command) {
194
- return `<div class="tool-input"><div class="tool-input-label">Command:</div><pre><code>${escapeHtml(input.command)}</code></pre></div>`;
190
+ return `<div class="tool-input"><div class="tool-input-label">Command:</div><pre><code>${(0, util_1.escapeHtml)(input.command)}</code></pre></div>`;
195
191
  }
196
192
  // Show old_string/new_string for Edit
197
193
  if (name === 'Edit' && input.old_string) {
198
- return `<div class="tool-input"><div class="tool-input-label">Edit:</div><div class="diff"><div class="diff-del">${escapeHtml(input.old_string)}</div><div class="diff-add">${escapeHtml(input.new_string || '')}</div></div></div>`;
194
+ return `<div class="tool-input"><div class="tool-input-label">Edit:</div><div class="diff"><div class="diff-del">${(0, util_1.escapeHtml)(input.old_string)}</div><div class="diff-add">${(0, util_1.escapeHtml)(input.new_string || '')}</div></div></div>`;
199
195
  }
200
196
  // For other tools, show input as JSON if small enough
201
197
  const json = JSON.stringify(input, null, 2);
202
198
  if (json.length > 500)
203
199
  return '';
204
- return `<div class="tool-input"><pre><code>${escapeHtml(json)}</code></pre></div>`;
200
+ return `<div class="tool-input"><pre><code>${(0, util_1.escapeHtml)(json)}</code></pre></div>`;
205
201
  }
206
202
  function renderSubagent(agentId, messages) {
207
203
  const agentHtml = messages.map(renderMessage).join('\n');
208
- return `<div class="msg msg-subagent"><details><summary><span class="tool-summary">Subagent: ${escapeHtml(agentId)}</span></summary><div class="subagent-content">${agentHtml}</div></details></div>`;
204
+ return `<div class="msg msg-subagent"><details><summary><span class="tool-summary">Subagent: ${(0, util_1.escapeHtml)(agentId)}</span></summary><div class="subagent-content">${agentHtml}</div></details></div>`;
209
205
  }
210
206
  function formatDateOnly(ts) {
211
207
  if (!ts)
@@ -299,7 +295,10 @@ function renderStats(stats) {
299
295
  return `<div class="stats-bar">${items.join('')}</div>`;
300
296
  }
301
297
  function generate(parsed, options = {}) {
302
- const { projectName, session, backUrl } = options;
298
+ const { projectName, projectRawName, session, backUrl, resume } = options;
299
+ const resumeButtons = session?.id && projectRawName
300
+ ? (0, resume_ui_1.resumeButtonsHtml)(projectRawName, { id: session.id, cwd: session.cwd ?? null, lastModified: session.lastModified ?? 0 }, resume)
301
+ : '';
303
302
  const title = session?.customTitle || session?.aiTitle || session?.slug || session?.id || 'Session';
304
303
  const date = session?.timestamp
305
304
  ? new Date(session.timestamp).toLocaleDateString('en-CA')
@@ -309,26 +308,27 @@ function generate(parsed, options = {}) {
309
308
  // Build grouped HTML with date anchors
310
309
  const groupsHtml = dateGroups.map(g => {
311
310
  const msgsHtml = g.messages.map(renderMessage).join('\n');
312
- return `<div class="detail-date-group" id="date-${g.date}" data-date="${escapeHtml(g.date)}">
313
- <div class="detail-date-heading">${escapeHtml(g.date)}</div>
311
+ return `<div class="detail-date-group" id="date-${g.date}" data-date="${(0, util_1.escapeHtml)(g.date)}">
312
+ <div class="detail-date-heading">${(0, util_1.escapeHtml)(g.date)}</div>
314
313
  ${msgsHtml}
315
314
  </div>`;
316
315
  }).join('\n');
317
316
  // Side nav for dates
318
317
  const sideNavItems = dateGroups
319
318
  .filter(g => g.date !== 'unknown')
320
- .map(g => `<a class="detail-sidenav-item" href="#date-${g.date}" data-date="${escapeHtml(g.date)}">${escapeHtml(g.date)}</a>`).join('\n');
319
+ .map(g => `<a class="detail-sidenav-item" href="#date-${g.date}" data-date="${(0, util_1.escapeHtml)(g.date)}">${(0, util_1.escapeHtml)(g.date)}</a>`).join('\n');
321
320
  const backLink = backUrl
322
- ? `<div style="font-size:0.8rem;margin-bottom:8px"><a href="${escapeHtml(backUrl)}" style="color:var(--link);text-decoration:none">&larr; Back to sessions</a> &nbsp;|&nbsp; <a href="/" style="color:var(--link);text-decoration:none">All projects</a></div>`
321
+ ? `<div style="font-size:0.8rem;margin-bottom:8px"><a href="${(0, util_1.escapeHtml)(backUrl)}" style="color:var(--link);text-decoration:none">&larr; Back to sessions</a> &nbsp;|&nbsp; <a href="/" style="color:var(--link);text-decoration:none">Dashboard</a> &nbsp;|&nbsp; <a href="/projects" style="color:var(--link);text-decoration:none">All projects</a></div>`
323
322
  : '';
324
323
  return `<!DOCTYPE html>
325
324
  <html lang="en">
326
325
  <head>
327
326
  <meta charset="utf-8">
328
327
  <meta name="viewport" content="width=device-width, initial-scale=1">
329
- <title>${escapeHtml(title)} — ${escapeHtml(date)}</title>
328
+ <title>${(0, util_1.escapeHtml)(title)} — ${(0, util_1.escapeHtml)(date)}</title>
330
329
  <style>${(0, template_assets_1.getCSS)()}
331
330
  ${detailLayoutCSS()}
331
+ ${(0, resume_ui_1.resumeCSS)()}
332
332
  </style>
333
333
  </head>
334
334
  <body>
@@ -336,13 +336,14 @@ ${detailLayoutCSS()}
336
336
  <div class="detail-sticky-bar" id="detailStickyBar"></div>
337
337
  <header class="session-header">
338
338
  ${backLink}
339
- <h1>${escapeHtml(title)}</h1>
339
+ <h1>${(0, util_1.escapeHtml)(title)}</h1>
340
340
  <div class="session-meta">
341
- ${projectName ? `<span class="meta-item">Project: ${escapeHtml(projectName)}</span>` : ''}
342
- ${date ? `<span class="meta-item">Date: ${escapeHtml(date)}</span>` : ''}
343
- ${session?.gitBranch ? `<span class="meta-item">Branch: ${escapeHtml(session.gitBranch)}</span>` : ''}
344
- ${session?.model ? `<span class="meta-item">Model: ${escapeHtml(session.model)}</span>` : ''}
341
+ ${projectName ? `<span class="meta-item">Project: ${(0, util_1.escapeHtml)(projectName)}</span>` : ''}
342
+ ${date ? `<span class="meta-item">Date: ${(0, util_1.escapeHtml)(date)}</span>` : ''}
343
+ ${session?.gitBranch ? `<span class="meta-item">Branch: ${(0, util_1.escapeHtml)(session.gitBranch)}</span>` : ''}
344
+ ${session?.model ? `<span class="meta-item">Model: ${(0, util_1.escapeHtml)(session.model)}</span>` : ''}
345
345
  </div>
346
+ ${resumeButtons}
346
347
  ${renderStats(parsed.stats)}
347
348
  </header>
348
349
  <div class="detail-layout">
@@ -364,6 +365,7 @@ ${detailLayoutCSS()}
364
365
  </div>
365
366
  <script>${(0, template_assets_1.getAppJS)()}
366
367
  ${detailNavJS()}
368
+ ${(0, resume_ui_1.resumeJS)(resume)}
367
369
  </script>
368
370
  </body>
369
371
  </html>`;
package/dist/pages.js CHANGED
@@ -3,13 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.generateIndex = generateIndex;
4
4
  exports.generateSessionList = generateSessionList;
5
5
  const template_assets_1 = require("./template-assets");
6
- function escapeHtml(str) {
7
- return String(str)
8
- .replace(/&/g, '&amp;')
9
- .replace(/</g, '&lt;')
10
- .replace(/>/g, '&gt;')
11
- .replace(/"/g, '&quot;');
12
- }
6
+ const resume_ui_1 = require("./resume-ui");
7
+ const util_1 = require("./util");
13
8
  function formatDate(ts) {
14
9
  if (!ts)
15
10
  return '';
@@ -33,9 +28,10 @@ function pageShell(title, bodyHtml) {
33
28
  <head>
34
29
  <meta charset="utf-8">
35
30
  <meta name="viewport" content="width=device-width, initial-scale=1">
36
- <title>${escapeHtml(title)}</title>
31
+ <title>${(0, util_1.escapeHtml)(title)}</title>
37
32
  <style>${(0, template_assets_1.getCSS)()}
38
33
  ${indexCSS()}
34
+ ${(0, resume_ui_1.resumeCSS)()}
39
35
  </style>
40
36
  </head>
41
37
  <body>
@@ -79,6 +75,7 @@ function indexCSS() {
79
75
  }
80
76
  .list-item {
81
77
  display: block;
78
+ position: relative;
82
79
  padding: 14px 16px;
83
80
  border: 1px solid var(--border);
84
81
  border-radius: 8px;
@@ -88,6 +85,12 @@ function indexCSS() {
88
85
  transition: background 0.15s, border-color 0.15s;
89
86
  cursor: pointer;
90
87
  }
88
+ /* Full-cover navigation link for div-based rows (keeps <button>s out of <a>). */
89
+ .list-item-link {
90
+ position: absolute;
91
+ inset: 0;
92
+ z-index: 0;
93
+ }
91
94
  .list-item:hover {
92
95
  background: var(--bg-secondary);
93
96
  border-color: var(--link);
@@ -256,7 +259,7 @@ function generateIndex(projects) {
256
259
  const items = projects.map((p) => {
257
260
  const href = `/project/${encodeURIComponent(p.rawName)}`;
258
261
  return `<a class="list-item" href="${href}">
259
- <div class="list-item-title">${escapeHtml(p.name)}</div>
262
+ <div class="list-item-title">${(0, util_1.escapeHtml)(p.name)}</div>
260
263
  <div class="list-item-meta">
261
264
  <span>${p.sessionCount} sessions</span>
262
265
  <span>Last: ${formatDate(p.lastModified)}</span>
@@ -265,6 +268,7 @@ function generateIndex(projects) {
265
268
  }).join('\n');
266
269
  return pageShell('ccakashic', `
267
270
  <div class="page-header">
271
+ <div class="breadcrumb"><a href="/">&larr; dashboard</a></div>
268
272
  <h1>ccakashic</h1>
269
273
  <div class="subtitle">Claude Code Session Logs</div>
270
274
  </div>
@@ -293,7 +297,7 @@ function formatTimeOnly(ts) {
293
297
  const d = ts instanceof Date ? ts : new Date(ts);
294
298
  return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
295
299
  }
296
- function generateSessionList(project, sessions) {
300
+ function generateSessionList(project, sessions, resume) {
297
301
  const dateGroups = [];
298
302
  let currentDate = null;
299
303
  for (const s of sessions) {
@@ -305,20 +309,20 @@ function generateSessionList(project, sessions) {
305
309
  dateGroups[dateGroups.length - 1].sessions.push(s);
306
310
  }
307
311
  // Build side nav
308
- const sideNavItems = dateGroups.map(g => `<a class="sidenav-item" href="#date-${g.date}" data-date="${escapeHtml(g.date)}">${escapeHtml(g.date)} <span class="sidenav-count">(${g.sessions.length})</span></a>`).join('\n');
312
+ const sideNavItems = dateGroups.map(g => `<a class="sidenav-item" href="#date-${g.date}" data-date="${(0, util_1.escapeHtml)(g.date)}">${(0, util_1.escapeHtml)(g.date)} <span class="sidenav-count">(${g.sessions.length})</span></a>`).join('\n');
309
313
  // Build session items grouped by date
310
314
  const groupsHtml = dateGroups.map(g => {
311
315
  const items = g.sessions.map(s => {
312
316
  const href = `/project/${encodeURIComponent(project.rawName)}/session/${encodeURIComponent(s.id)}`;
313
317
  const lastModTime = formatTimeOnly(new Date(s.lastModified));
314
318
  const startedTime = formatDate(s.timestamp);
315
- const slug = s.slug ? `<span class="badge">${escapeHtml(s.slug)}</span>` : '';
319
+ const slug = s.slug ? `<span class="badge">${(0, util_1.escapeHtml)(s.slug)}</span>` : '';
316
320
  const sub = s.hasSubagents ? '<span class="badge">subagents</span>' : '';
317
- const model = s.model ? `<span>${escapeHtml(s.model)}</span>` : '';
318
- const branch = s.gitBranch && s.gitBranch !== 'HEAD' ? `<span>branch: ${escapeHtml(s.gitBranch)}</span>` : '';
321
+ const model = s.model ? `<span>${(0, util_1.escapeHtml)(s.model)}</span>` : '';
322
+ const branch = s.gitBranch && s.gitBranch !== 'HEAD' ? `<span>branch: ${(0, util_1.escapeHtml)(s.gitBranch)}</span>` : '';
319
323
  const tokens = s.totalTokens ? `<span>${formatTokens(s.totalTokens)} tokens</span>` : '';
320
324
  const outTok = s.outputTokens ? `<span>out: ${formatTokens(s.outputTokens)}</span>` : '';
321
- const preview = s.preview ? `<div class="list-item-preview">${escapeHtml(s.preview)}</div>` : '';
325
+ const preview = s.preview ? `<div class="list-item-preview">${(0, util_1.escapeHtml)(s.preview)}</div>` : '';
322
326
  // Prefer a human/AI-given name as the headline; renamed sessions get a
323
327
  // distinct treatment so they're easy to spot in the list.
324
328
  const displayName = s.customTitle || s.aiTitle;
@@ -328,25 +332,31 @@ function generateSessionList(project, sessions) {
328
332
  ? '<span class="badge badge-ai">AI</span>'
329
333
  : '';
330
334
  const titleRow = displayName
331
- ? `<div class="list-item-title"><span class="list-item-name${s.customTitle ? ' is-named' : ''}">${escapeHtml(displayName)}</span> ${nameBadge} ${slug} ${sub}</div>
335
+ ? `<div class="list-item-title"><span class="list-item-name${s.customTitle ? ' is-named' : ''}">${(0, util_1.escapeHtml)(displayName)}</span> ${nameBadge} ${slug} ${sub}</div>
332
336
  <div class="list-item-meta"><span>${lastModTime}</span><span>started: ${startedTime}</span>${model}${branch}${tokens}${outTok}</div>`
333
337
  : `<div class="list-item-title">${lastModTime} ${slug} ${sub}</div>
334
338
  <div class="list-item-meta"><span>started: ${startedTime}</span>${model}${branch}${tokens}${outTok}</div>`;
335
- return `<a class="list-item" href="${href}">
339
+ // Stretched-link pattern: the row is a <div> (not an <a>) so the resume
340
+ // <button>s aren't nested inside an anchor (invalid HTML). A full-cover
341
+ // link handles row navigation; the buttons sit above it via z-index.
342
+ const label = (0, util_1.escapeHtml)(displayName || s.id);
343
+ return `<div class="list-item">
344
+ <a class="list-item-link" href="${href}" aria-label="${label}"></a>
336
345
  ${titleRow}
337
346
  ${preview}
338
- </a>`;
347
+ ${(0, resume_ui_1.resumeButtonsHtml)(project.rawName, s, resume)}
348
+ </div>`;
339
349
  }).join('\n');
340
- return `<div class="date-group" id="date-${g.date}" data-date="${escapeHtml(g.date)}">
341
- <div class="date-heading">${escapeHtml(g.date)}</div>
350
+ return `<div class="date-group" id="date-${g.date}" data-date="${(0, util_1.escapeHtml)(g.date)}">
351
+ <div class="date-heading">${(0, util_1.escapeHtml)(g.date)}</div>
342
352
  ${items}
343
353
  </div>`;
344
354
  }).join('\n');
345
355
  return pageShell(`${project.name} — ccakashic`, `
346
356
  <div class="sticky-date-bar" id="stickyDateBar"></div>
347
357
  <div class="page-header">
348
- <div class="breadcrumb"><a href="/">ccakashic</a> / ${escapeHtml(project.name)}</div>
349
- <h1>${escapeHtml(project.name)}</h1>
358
+ <div class="breadcrumb"><a href="/">dashboard</a> / <a href="/projects">projects</a> / ${(0, util_1.escapeHtml)(project.name)}</div>
359
+ <h1>${(0, util_1.escapeHtml)(project.name)}</h1>
350
360
  <div class="subtitle">${sessions.length} sessions</div>
351
361
  </div>
352
362
  <div class="session-layout">
@@ -402,5 +412,6 @@ function generateSessionList(project, sessions) {
402
412
  window.addEventListener('scroll', updateCurrentDate, { passive: true });
403
413
  updateCurrentDate();
404
414
  })();
415
+ ${(0, resume_ui_1.resumeJS)(resume)}
405
416
  </script>`);
406
417
  }
package/dist/parser.js CHANGED
@@ -33,10 +33,37 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseSessionCached = parseSessionCached;
36
37
  exports.parseSession = parseSession;
37
38
  const fs = __importStar(require("fs"));
38
39
  const path = __importStar(require("path"));
39
40
  const readline = __importStar(require("readline"));
41
+ // Small LRU cache keyed on (path, mtime). The dashboard's server-side render
42
+ // and the /api/pane poll can ask for the same (unchanged) session, and several
43
+ // panes/clients may show the same session; this avoids re-reading and
44
+ // re-parsing the whole file for an identical mtime. NOTE: an actively-changing
45
+ // session gets a new mtime each poll (cache miss) and is still fully re-parsed;
46
+ // a future optimization could read only the bytes appended past the last offset.
47
+ const parseCache = new Map();
48
+ const PARSE_CACHE_MAX = 32;
49
+ async function parseSessionCached(sessionPath, mtimeMs) {
50
+ const key = sessionPath;
51
+ const hit = parseCache.get(key);
52
+ if (hit && hit.mtimeMs === mtimeMs) {
53
+ // Refresh LRU recency.
54
+ parseCache.delete(key);
55
+ parseCache.set(key, hit);
56
+ return hit.parsed;
57
+ }
58
+ const parsed = await parseSession(sessionPath);
59
+ parseCache.set(key, { mtimeMs, parsed });
60
+ if (parseCache.size > PARSE_CACHE_MAX) {
61
+ const oldest = parseCache.keys().next().value;
62
+ if (oldest !== undefined)
63
+ parseCache.delete(oldest);
64
+ }
65
+ return parsed;
66
+ }
40
67
  async function parseSession(sessionPath) {
41
68
  const lines = await readJsonlLines(sessionPath);
42
69
  const messages = buildConversation(lines);
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resumeButtonsHtml = resumeButtonsHtml;
4
+ exports.resumeCSS = resumeCSS;
5
+ exports.resumeJS = resumeJS;
6
+ const cmux_1 = require("./cmux");
7
+ const util_1 = require("./util");
8
+ function resumeButtonsHtml(projectRawName, session, ctx) {
9
+ if (!ctx || !session.cwd)
10
+ return '';
11
+ const isOpen = ctx.openSessionIds.has(session.id);
12
+ const isActive = Date.now() - session.lastModified < util_1.ACTIVE_THRESHOLD_MS;
13
+ const resumeBtn = ctx.cmuxAvailable
14
+ ? `<button type="button" class="resume-btn${isOpen ? ' is-open' : ''}"
15
+ data-project="${(0, util_1.escapeHtml)(projectRawName)}"
16
+ data-session="${(0, util_1.escapeHtml)(session.id)}"
17
+ data-open="${isOpen ? '1' : '0'}"
18
+ data-active="${isActive ? '1' : '0'}"
19
+ title="${isOpen ? 'Jump to the open cmux workspace' : 'Resume in a new cmux workspace (Alt-click: open in background)'}">${isOpen ? '&#8618; Jump' : '&#9654; Resume'}</button>`
20
+ : '';
21
+ const copyBtn = `<button type="button" class="copy-cmd-btn"
22
+ data-cmd="${(0, util_1.escapeHtml)((0, cmux_1.buildResumeCommand)(session.cwd, session.id))}"
23
+ title="Copy the cd + claude --resume command">&#128203; Copy</button>`;
24
+ return `<div class="resume-actions">${resumeBtn}${copyBtn}</div>`;
25
+ }
26
+ function resumeCSS() {
27
+ return `
28
+ .resume-actions {
29
+ display: inline-flex;
30
+ gap: 6px;
31
+ margin-top: 6px;
32
+ /* Sit above the stretched .list-item-link so the buttons stay clickable. */
33
+ position: relative;
34
+ z-index: 1;
35
+ }
36
+ .resume-btn, .copy-cmd-btn {
37
+ font-size: 0.72rem;
38
+ font-weight: 600;
39
+ padding: 3px 10px;
40
+ border-radius: 5px;
41
+ border: 1px solid var(--border);
42
+ background: var(--tool-bg);
43
+ color: var(--text);
44
+ cursor: pointer;
45
+ transition: background 0.15s, border-color 0.15s;
46
+ }
47
+ .resume-btn:hover, .copy-cmd-btn:hover {
48
+ border-color: var(--link);
49
+ background: var(--bg-secondary);
50
+ }
51
+ .resume-btn.is-open {
52
+ color: var(--link);
53
+ border-color: var(--link);
54
+ }
55
+ .resume-btn:disabled, .copy-cmd-btn:disabled { opacity: 0.5; cursor: wait; }
56
+ .resume-toast {
57
+ position: fixed;
58
+ bottom: 24px;
59
+ left: 50%;
60
+ transform: translateX(-50%);
61
+ background: var(--text);
62
+ color: var(--bg);
63
+ padding: 8px 18px;
64
+ border-radius: 8px;
65
+ font-size: 0.85rem;
66
+ z-index: 1000;
67
+ opacity: 0;
68
+ transition: opacity 0.2s;
69
+ pointer-events: none;
70
+ }
71
+ .resume-toast.visible { opacity: 1; }
72
+ `;
73
+ }
74
+ function resumeJS(ctx) {
75
+ if (!ctx)
76
+ return '';
77
+ return `
78
+ (function() {
79
+ var TOKEN = ${JSON.stringify(ctx.token)};
80
+ var toastEl = null;
81
+ function toast(msg) {
82
+ if (!toastEl) {
83
+ toastEl = document.createElement('div');
84
+ toastEl.className = 'resume-toast';
85
+ document.body.appendChild(toastEl);
86
+ }
87
+ toastEl.textContent = msg;
88
+ toastEl.classList.add('visible');
89
+ clearTimeout(toastEl._t);
90
+ toastEl._t = setTimeout(function() { toastEl.classList.remove('visible'); }, 2500);
91
+ }
92
+ function copy(text) {
93
+ navigator.clipboard.writeText(text).then(
94
+ function() { toast('Command copied'); },
95
+ function() { toast('Copy failed'); }
96
+ );
97
+ }
98
+ document.addEventListener('click', function(e) {
99
+ var btn = e.target.closest ? e.target.closest('.resume-btn, .copy-cmd-btn') : null;
100
+ if (!btn) return;
101
+ e.preventDefault();
102
+ e.stopPropagation();
103
+
104
+ if (btn.classList.contains('copy-cmd-btn')) {
105
+ copy(btn.dataset.cmd);
106
+ return;
107
+ }
108
+
109
+ // Resuming a session that still looks active forks the conversation;
110
+ // require a second click to confirm.
111
+ if (btn.dataset.active === '1' && btn.dataset.open !== '1' && !btn.dataset.confirm) {
112
+ btn.dataset.confirm = '1';
113
+ var orig = btn.innerHTML;
114
+ btn.innerHTML = '&#9888; Fork? click again';
115
+ setTimeout(function() { delete btn.dataset.confirm; btn.innerHTML = orig; }, 3000);
116
+ return;
117
+ }
118
+ delete btn.dataset.confirm;
119
+
120
+ var mode = e.altKey ? 'background' : 'jump';
121
+ btn.disabled = true;
122
+ fetch('/api/resume', {
123
+ method: 'POST',
124
+ headers: { 'Content-Type': 'application/json', 'X-Ccakashic-Token': TOKEN },
125
+ body: JSON.stringify({ project: btn.dataset.project, session: btn.dataset.session, mode: mode })
126
+ }).then(function(res) { return res.json(); }).then(function(data) {
127
+ btn.disabled = false;
128
+ if (data.action === 'jumped') {
129
+ toast('Jumped to the open cmux workspace');
130
+ } else if (data.action === 'resumed') {
131
+ toast(mode === 'background' ? 'Resumed in a background cmux workspace' : 'Resumed in a new cmux workspace');
132
+ btn.dataset.open = '1';
133
+ btn.dataset.active = '1';
134
+ btn.classList.add('is-open');
135
+ btn.innerHTML = '&#8618; Jump';
136
+ } else if (data.action === 'unavailable' && data.command) {
137
+ copy(data.command);
138
+ } else {
139
+ toast(data.message || 'Resume failed');
140
+ }
141
+ }).catch(function() {
142
+ btn.disabled = false;
143
+ toast('Resume failed: server unreachable');
144
+ });
145
+ }, true);
146
+ })();
147
+ `;
148
+ }