mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 21:49:42 +02:00
feat: improve session history with first prompt + clickable monitor rows (closes #45)
This commit is contained in:
@@ -3049,8 +3049,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
modelHtml = `<span class="monitor-model-badge ${modelShort}">${modelShort}</span>`;
|
||||
}
|
||||
|
||||
const sid = escapeHtml(muxSession.sessionId);
|
||||
html += `
|
||||
<div class="process-item">
|
||||
<div class="process-item process-item-clickable" onclick="app.selectSession('${sid}')" title="Switch to session">
|
||||
<span class="monitor-status-badge ${statusClass}">${statusLabel}</span>
|
||||
<div class="process-info">
|
||||
<div class="process-name">${modelHtml} ${escapeHtml(muxSession.name || muxSession.muxName)}</div>
|
||||
@@ -3063,7 +3064,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
</div>
|
||||
<div class="process-actions">
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="app.killMuxSession('${escapeHtml(muxSession.sessionId)}')" title="Kill session">Kill</button>
|
||||
<button class="btn-toolbar btn-sm btn-danger" onclick="event.stopPropagation(); app.killMuxSession('${sid}')" title="Kill session">Kill</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -2134,8 +2134,15 @@ body {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.history-item-dir {
|
||||
.history-item-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.history-item-title {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
@@ -2143,6 +2150,14 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-subtitle {
|
||||
font-size: 0.65rem;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-item-meta {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted);
|
||||
@@ -5011,6 +5026,16 @@ kbd {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.process-item-clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.process-item-clickable:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.process-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -704,9 +704,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
item.title = s.workingDir;
|
||||
item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
|
||||
|
||||
const dirSpan = document.createElement('span');
|
||||
dirSpan.className = 'history-item-dir';
|
||||
dirSpan.textContent = shortDir;
|
||||
const textCol = document.createElement('div');
|
||||
textCol.className = 'history-item-text';
|
||||
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.className = 'history-item-title';
|
||||
titleSpan.textContent = s.firstPrompt || shortDir;
|
||||
|
||||
const subtitleSpan = document.createElement('span');
|
||||
subtitleSpan.className = 'history-item-subtitle';
|
||||
subtitleSpan.textContent = shortDir;
|
||||
|
||||
textCol.append(titleSpan, subtitleSpan);
|
||||
|
||||
const metaSpan = document.createElement('span');
|
||||
metaSpan.className = 'history-item-meta';
|
||||
@@ -716,7 +725,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
sizeSpan.className = 'history-item-size';
|
||||
sizeSpan.textContent = size;
|
||||
|
||||
item.append(dirSpan, metaSpan, sizeSpan);
|
||||
item.append(textCol, metaSpan, sizeSpan);
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -962,6 +962,39 @@ export function registerSessionRoutes(
|
||||
// History — list past Claude conversations for resume
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/** Extract the text of the first user message from a JSONL transcript head. */
|
||||
function extractFirstUserPrompt(head: string): string | undefined {
|
||||
const MAX_PROMPT_LEN = 120;
|
||||
for (const line of head.split('\n')) {
|
||||
if (!line.includes('"type":"user"')) continue;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type !== 'user' || !entry.message) continue;
|
||||
const content = entry.message.content;
|
||||
let text: string | undefined;
|
||||
if (typeof content === 'string') {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
const textBlock = content.find((b: { type: string }) => b.type === 'text');
|
||||
if (textBlock) text = textBlock.text;
|
||||
}
|
||||
if (!text) continue;
|
||||
// Strip XML-like system/command tags that Claude Code injects into transcripts
|
||||
text = text
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ');
|
||||
if (!text) continue;
|
||||
// Skip system-injected messages and slash command artifacts (not real user prompts)
|
||||
if (/^(Caveat:|init\b|clear\b|\/\w+ \w+$|You are a )/i.test(text)) continue;
|
||||
return text.length > MAX_PROMPT_LEN ? text.slice(0, MAX_PROMPT_LEN) + '…' : text;
|
||||
} catch {
|
||||
// Malformed line — skip
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
app.get('/api/history/sessions', async () => {
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
const results: Array<{
|
||||
@@ -970,6 +1003,7 @@ export function registerSessionRoutes(
|
||||
projectKey: string;
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
}> = [];
|
||||
|
||||
try {
|
||||
@@ -1006,24 +1040,35 @@ export function registerSessionRoutes(
|
||||
// Quick content check: verify actual conversation data exists.
|
||||
// Sessions with only file-history-snapshot or hook_progress entries have
|
||||
// no "user"/"assistant" messages and will fail claude --resume.
|
||||
// Files > 50KB are almost certainly real conversations (skip the read).
|
||||
if (fileStat.size < 50000) {
|
||||
// Read first 16KB to check content and extract first user prompt.
|
||||
let firstPrompt: string | undefined;
|
||||
const readHead = async (): Promise<string | null> => {
|
||||
try {
|
||||
const fd = await fs.open(filePath, 'r');
|
||||
const buf = Buffer.alloc(16384);
|
||||
const { bytesRead } = await fd.read(buf, 0, 16384, 0);
|
||||
await fd.close();
|
||||
const head = buf.toString('utf8', 0, bytesRead);
|
||||
if (
|
||||
!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"')
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
return buf.toString('utf8', 0, bytesRead);
|
||||
} catch {
|
||||
continue;
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (fileStat.size < 50000) {
|
||||
const head = await readHead();
|
||||
if (
|
||||
!head ||
|
||||
(!head.includes('"type":"user"') &&
|
||||
!head.includes('"type":"assistant"') &&
|
||||
!head.includes('"type":"summary"'))
|
||||
) {
|
||||
continue; // No conversation content — skip
|
||||
}
|
||||
firstPrompt = extractFirstUserPrompt(head);
|
||||
} else {
|
||||
// Large files are almost certainly real conversations; still read head for prompt
|
||||
const head = await readHead();
|
||||
if (head) firstPrompt = extractFirstUserPrompt(head);
|
||||
}
|
||||
|
||||
results.push({
|
||||
@@ -1032,6 +1077,7 @@ export function registerSessionRoutes(
|
||||
projectKey: projDir,
|
||||
sizeBytes: fileStat.size,
|
||||
lastModified: fileStat.mtime.toISOString(),
|
||||
firstPrompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user