ccakashic 0.2.7 → 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,8 +295,11 @@ 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;
303
- const title = session?.slug || session?.id || 'Session';
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
+ : '';
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')
306
305
  : '';
@@ -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);
@@ -97,6 +100,12 @@ function indexCSS() {
97
100
  font-size: 0.95rem;
98
101
  margin-bottom: 4px;
99
102
  }
103
+ .list-item-name {
104
+ font-size: 1.02rem;
105
+ }
106
+ .list-item-name.is-named {
107
+ color: var(--link);
108
+ }
100
109
  .list-item-meta {
101
110
  font-size: 0.8rem;
102
111
  color: var(--text-muted);
@@ -121,6 +130,16 @@ function indexCSS() {
121
130
  background: var(--tool-bg);
122
131
  border: 1px solid var(--border);
123
132
  }
133
+ .badge-named {
134
+ background: var(--link);
135
+ border-color: var(--link);
136
+ color: #fff;
137
+ }
138
+ .badge-ai {
139
+ background: transparent;
140
+ border-color: var(--link);
141
+ color: var(--link);
142
+ }
124
143
  .search-box {
125
144
  width: 100%;
126
145
  padding: 10px 14px;
@@ -240,7 +259,7 @@ function generateIndex(projects) {
240
259
  const items = projects.map((p) => {
241
260
  const href = `/project/${encodeURIComponent(p.rawName)}`;
242
261
  return `<a class="list-item" href="${href}">
243
- <div class="list-item-title">${escapeHtml(p.name)}</div>
262
+ <div class="list-item-title">${(0, util_1.escapeHtml)(p.name)}</div>
244
263
  <div class="list-item-meta">
245
264
  <span>${p.sessionCount} sessions</span>
246
265
  <span>Last: ${formatDate(p.lastModified)}</span>
@@ -249,6 +268,7 @@ function generateIndex(projects) {
249
268
  }).join('\n');
250
269
  return pageShell('ccakashic', `
251
270
  <div class="page-header">
271
+ <div class="breadcrumb"><a href="/">&larr; dashboard</a></div>
252
272
  <h1>ccakashic</h1>
253
273
  <div class="subtitle">Claude Code Session Logs</div>
254
274
  </div>
@@ -277,7 +297,7 @@ function formatTimeOnly(ts) {
277
297
  const d = ts instanceof Date ? ts : new Date(ts);
278
298
  return d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
279
299
  }
280
- function generateSessionList(project, sessions) {
300
+ function generateSessionList(project, sessions, resume) {
281
301
  const dateGroups = [];
282
302
  let currentDate = null;
283
303
  for (const s of sessions) {
@@ -289,36 +309,54 @@ function generateSessionList(project, sessions) {
289
309
  dateGroups[dateGroups.length - 1].sessions.push(s);
290
310
  }
291
311
  // Build side nav
292
- 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');
293
313
  // Build session items grouped by date
294
314
  const groupsHtml = dateGroups.map(g => {
295
315
  const items = g.sessions.map(s => {
296
316
  const href = `/project/${encodeURIComponent(project.rawName)}/session/${encodeURIComponent(s.id)}`;
297
317
  const lastModTime = formatTimeOnly(new Date(s.lastModified));
298
318
  const startedTime = formatDate(s.timestamp);
299
- 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>` : '';
300
320
  const sub = s.hasSubagents ? '<span class="badge">subagents</span>' : '';
301
- const model = s.model ? `<span>${escapeHtml(s.model)}</span>` : '';
302
- 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>` : '';
303
323
  const tokens = s.totalTokens ? `<span>${formatTokens(s.totalTokens)} tokens</span>` : '';
304
324
  const outTok = s.outputTokens ? `<span>out: ${formatTokens(s.outputTokens)}</span>` : '';
305
- const preview = s.preview ? `<div class="list-item-preview">${escapeHtml(s.preview)}</div>` : '';
306
- return `<a class="list-item" href="${href}">
307
- <div class="list-item-title">${lastModTime} ${slug} ${sub}</div>
308
- <div class="list-item-meta"><span>started: ${startedTime}</span>${model}${branch}${tokens}${outTok}</div>
325
+ const preview = s.preview ? `<div class="list-item-preview">${(0, util_1.escapeHtml)(s.preview)}</div>` : '';
326
+ // Prefer a human/AI-given name as the headline; renamed sessions get a
327
+ // distinct treatment so they're easy to spot in the list.
328
+ const displayName = s.customTitle || s.aiTitle;
329
+ const nameBadge = s.customTitle
330
+ ? '<span class="badge badge-named">named</span>'
331
+ : s.aiTitle
332
+ ? '<span class="badge badge-ai">AI</span>'
333
+ : '';
334
+ const titleRow = displayName
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>
336
+ <div class="list-item-meta"><span>${lastModTime}</span><span>started: ${startedTime}</span>${model}${branch}${tokens}${outTok}</div>`
337
+ : `<div class="list-item-title">${lastModTime} ${slug} ${sub}</div>
338
+ <div class="list-item-meta"><span>started: ${startedTime}</span>${model}${branch}${tokens}${outTok}</div>`;
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>
345
+ ${titleRow}
309
346
  ${preview}
310
- </a>`;
347
+ ${(0, resume_ui_1.resumeButtonsHtml)(project.rawName, s, resume)}
348
+ </div>`;
311
349
  }).join('\n');
312
- return `<div class="date-group" id="date-${g.date}" data-date="${escapeHtml(g.date)}">
313
- <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>
314
352
  ${items}
315
353
  </div>`;
316
354
  }).join('\n');
317
355
  return pageShell(`${project.name} — ccakashic`, `
318
356
  <div class="sticky-date-bar" id="stickyDateBar"></div>
319
357
  <div class="page-header">
320
- <div class="breadcrumb"><a href="/">ccakashic</a> / ${escapeHtml(project.name)}</div>
321
- <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>
322
360
  <div class="subtitle">${sessions.length} sessions</div>
323
361
  </div>
324
362
  <div class="session-layout">
@@ -374,5 +412,6 @@ function generateSessionList(project, sessions) {
374
412
  window.addEventListener('scroll', updateCurrentDate, { passive: true });
375
413
  updateCurrentDate();
376
414
  })();
415
+ ${(0, resume_ui_1.resumeJS)(resume)}
377
416
  </script>`);
378
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
+ }