COD-145 show last (most recent) prompt alongside first in session manager

Building on COD-140's firstPrompt backfill, surface each session's most
recent user prompt too, so a long-running session is identifiable by both
where it started and where it is now.

- session-routes: add extractLastUserPrompt() (mirrors extractFirstUserPrompt
  with last-match semantics + same noise/secret/slash-command filters + 120
  cap); scanProjectDir computes lastPrompt from the file tail (reads a tail for
  large files; small files scan head); thread lastPrompt through HistorySession
  and the /api/sessions/unified history rows.
- unified-session-service: add lastPrompt to UnifiedSessionItem + HistoryInput,
  set it from history in the merge, and extend the backfill with parallel
  by-uuid / newest-by-workingDir indexes (never overwrites); add lastPrompt to
  the filterAndPaginate search haystack.
- terminal-ui: render a 'Last prompt' detail row, omitted when absent or equal
  to the first prompt (single-prompt sessions show one line).

Tests: unified-session-service.test.ts +5 (uuid-join, workingDir fallback,
newest-wins, no-overwrite, search). Beta-verified: /api/sessions/unified
populated firstPrompt+lastPrompt on all 200 rows (12 distinct); Playwright on
the session-manager modal rendered 12 'Last prompt' rows, 0 console errors.

(cherry picked from commit 115f4d397e91decc1a6381b47a99d74922e9055b)
This commit is contained in:
Aamer Akhter
2026-07-17 16:31:09 -04:00
parent b27a0e9188
commit 5ec71ace5a
4 changed files with 245 additions and 23 deletions
+18 -1
View File
@@ -1338,6 +1338,21 @@ Object.assign(CodemanApp.prototype, {
promptText.textContent = s.firstPrompt || '(no prompt captured)';
promptRow.append(promptLabel, promptText);
// COD-145: show the most recent user prompt too, but collapse single-prompt
// sessions (omit when there's no last prompt or it duplicates the first).
let lastPromptRow = null;
if (s.lastPrompt && s.lastPrompt !== s.firstPrompt) {
lastPromptRow = document.createElement('div');
lastPromptRow.className = 'history-detail-row';
const lastPromptLabel = document.createElement('span');
lastPromptLabel.className = 'history-detail-label';
lastPromptLabel.textContent = 'Last prompt';
const lastPromptText = document.createElement('span');
lastPromptText.className = 'history-detail-value history-detail-prompt';
lastPromptText.textContent = s.lastPrompt;
lastPromptRow.append(lastPromptLabel, lastPromptText);
}
const pathRow = document.createElement('div');
pathRow.className = 'history-detail-row';
const pathLabel = document.createElement('span');
@@ -1356,7 +1371,9 @@ Object.assign(CodemanApp.prototype, {
metaParts.push(s.sessionId.slice(0, 8));
metaRow.textContent = metaParts.join(' · ');
detail.append(promptRow, pathRow, metaRow);
detail.append(promptRow);
if (lastPromptRow) detail.append(lastPromptRow);
detail.append(pathRow, metaRow);
if (showViewAll && s.projectKey) {
const actionRow = document.createElement('div');
+67
View File
@@ -2024,6 +2024,59 @@ export function registerSessionRoutes(
return undefined;
}
/**
* Extract the text of the LAST user message from a JSONL transcript chunk
* (COD-145). Mirrors `extractFirstUserPrompt` exactly — same user-message
* detection, same noise/secret/slash-command filters, same 120-char cap — but
* keeps the last qualifying match instead of returning on the first. Scan the
* file tail for this (the most recent prompt lives near the end).
*/
function extractLastUserPrompt(text: string): string | undefined {
const MAX_PROMPT_LEN = 120;
let result: string | undefined;
let start = 0;
while (start < text.length) {
const end = text.indexOf('\n', start);
const line = end === -1 ? text.slice(start) : text.slice(start, end);
start = end === -1 ? text.length : end + 1;
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 msgText: string | undefined;
if (typeof content === 'string') {
msgText = content;
} else if (Array.isArray(content)) {
const textBlock = content.find((b: { type: string }) => b.type === 'text');
if (textBlock) msgText = textBlock.text;
}
if (!msgText) continue;
msgText = msgText
.replace(/<[^>]+>/g, '')
.replace(new RegExp(String.raw`\x1b\[[0-9;]*[a-zA-Z]`, 'g'), '')
.trim()
.replace(/\s+/g, ' ');
if (!msgText) continue;
if (
/^(Caveat:|init\b|clear\b|resume\b|\/[a-z][\w-]*\b|You are a |\[Request |Set model to )/i.test(msgText) ||
/^(Please )?(analyze|review) this codebase/i.test(msgText) ||
/^(Read|Implement the following) .+, then (search|list|check) /i.test(msgText) ||
/^\d+ vulnerabilit/i.test(msgText) ||
/\btoolu_/.test(msgText) ||
/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]+/.test(msgText) ||
/\b(sk-ant-|ANTHROPIC_API_KEY|API_KEY=|SECRET|TOKEN=)/i.test(msgText) ||
msgText.length < 8
)
continue;
result = msgText.length > MAX_PROMPT_LEN ? msgText.slice(0, MAX_PROMPT_LEN) + '…' : msgText;
} catch {
// Malformed line — skip
}
}
return result;
}
/**
* Decode a Claude project key (e.g. "-Users-teigen-Documents-Workspace-AI-project-Mirror")
* back to a filesystem path ("/Users/teigen/Documents/Workspace/AI_project/Mirror").
@@ -2162,6 +2215,7 @@ export function registerSessionRoutes(
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
lastPrompt?: string;
};
// Scan a single project directory and return all valid history sessions in it.
@@ -2207,6 +2261,17 @@ export function registerSessionRoutes(
if (tail) firstPrompt = extractFirstUserPrompt(tail);
}
// COD-145: last (most recent) user prompt lives near the END of the file, so
// prefer the tail. For large files where no tail was read yet, read one
// (mirrors the firstPrompt > 65536 block). Small files fit in `head`, which
// then contains the whole transcript — scan it for the last match instead.
if (!tail && fileStat.size > 65536) {
const tailBuf = Buffer.alloc(32768);
tail = await readFileTail(filePath, tailBuf, fileStat.size);
}
const lastPrompt =
(tail ? extractLastUserPrompt(tail) : undefined) ?? (head ? extractLastUserPrompt(head) : undefined);
out.push({
sessionId,
workingDir,
@@ -2214,6 +2279,7 @@ export function registerSessionRoutes(
sizeBytes: fileStat.size,
lastModified: fileStat.mtime.toISOString(),
firstPrompt,
lastPrompt,
});
}
return out;
@@ -2331,6 +2397,7 @@ export function registerSessionRoutes(
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
lastPrompt: h.lastPrompt,
projectKey: h.projectKey,
});
}