mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
@@ -28,6 +28,8 @@ export type UnifiedSessionItem = {
|
||||
lastActivityAt?: number;
|
||||
claudeSessionId?: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145), parallel to firstPrompt. */
|
||||
lastPrompt?: string;
|
||||
sizeBytes?: number;
|
||||
projectKey?: string;
|
||||
remote?: boolean;
|
||||
@@ -85,6 +87,8 @@ export type HistoryInput = {
|
||||
sizeBytes: number;
|
||||
lastModified: string;
|
||||
firstPrompt?: string;
|
||||
/** Most recent user prompt from the transcript (COD-145). */
|
||||
lastPrompt?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
|
||||
@@ -157,6 +161,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
overwrite(item, 'workingDir', h.workingDir);
|
||||
overwrite(item, 'sizeBytes', h.sizeBytes);
|
||||
overwrite(item, 'firstPrompt', h.firstPrompt);
|
||||
overwrite(item, 'lastPrompt', h.lastPrompt);
|
||||
overwrite(item, 'projectKey', h.projectKey);
|
||||
const ms = Date.parse(h.lastModified);
|
||||
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
|
||||
@@ -223,30 +228,50 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
// firstPrompt (so rows keyed to their own transcript are untouched).
|
||||
const firstPromptByUuid = new Map<string, string>();
|
||||
const firstPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
// COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite).
|
||||
const lastPromptByUuid = new Map<string, string>();
|
||||
const lastPromptByWorkingDir = new Map<string, { prompt: string; ms: number }>();
|
||||
for (const h of sources.history ?? []) {
|
||||
if (!h.firstPrompt) continue;
|
||||
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||
if (h.workingDir) {
|
||||
const ms = Date.parse(h.lastModified);
|
||||
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||
const ms = Date.parse(h.lastModified);
|
||||
const ts = Number.isNaN(ms) ? -Infinity : ms;
|
||||
if (h.firstPrompt) {
|
||||
firstPromptByUuid.set(h.sessionId, h.firstPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = firstPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
firstPromptByWorkingDir.set(h.workingDir, { prompt: h.firstPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (h.lastPrompt) {
|
||||
lastPromptByUuid.set(h.sessionId, h.lastPrompt);
|
||||
if (h.workingDir) {
|
||||
const existing = lastPromptByWorkingDir.get(h.workingDir);
|
||||
if (!existing || ts > existing.ms) {
|
||||
lastPromptByWorkingDir.set(h.workingDir, { prompt: h.lastPrompt, ms: ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const item of map.values()) {
|
||||
if (item.firstPrompt) continue; // never overwrite an existing non-empty prompt
|
||||
if (item.claudeSessionId) {
|
||||
const byUuid = firstPromptByUuid.get(item.claudeSessionId);
|
||||
if (!item.firstPrompt) {
|
||||
// never overwrite an existing non-empty prompt
|
||||
const byUuid = item.claudeSessionId ? firstPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.firstPrompt = byUuid;
|
||||
continue;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
if (item.workingDir) {
|
||||
const byDir = firstPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.firstPrompt = byDir.prompt;
|
||||
if (!item.lastPrompt) {
|
||||
const byUuid = item.claudeSessionId ? lastPromptByUuid.get(item.claudeSessionId) : undefined;
|
||||
if (byUuid) {
|
||||
item.lastPrompt = byUuid;
|
||||
} else if (item.workingDir) {
|
||||
const byDir = lastPromptByWorkingDir.get(item.workingDir);
|
||||
if (byDir) item.lastPrompt = byDir.prompt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +325,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive substring filter (name + firstPrompt + workingDir + sessionId)
|
||||
* Case-insensitive substring filter (name + firstPrompt + lastPrompt + workingDir + sessionId)
|
||||
* with offset/limit paging. `total` is the filtered count BEFORE paging.
|
||||
*/
|
||||
export function filterAndPaginate(
|
||||
@@ -310,7 +335,7 @@ export function filterAndPaginate(
|
||||
const q = (opts.q ?? '').trim().toLowerCase();
|
||||
const filtered = q
|
||||
? items.filter((it) => {
|
||||
const hay = [it.name, it.firstPrompt, it.workingDir, it.sessionId]
|
||||
const hay = [it.name, it.firstPrompt, it.lastPrompt, it.workingDir, it.sessionId]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,6 +269,105 @@ describe('mergeUnifiedSessions', () => {
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.firstPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
// COD-145: lastPrompt backfill — mirrors the COD-140 firstPrompt path so the
|
||||
// most-recent user prompt also reaches live rows whose id ≠ transcript UUID.
|
||||
it('backfills lastPrompt onto a live session by claudeSessionId join (uuid-join)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-l1', status: 'working', claudeSessionId: 'uuid-LA', workingDir: '/wl' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-LA',
|
||||
workingDir: '/wl',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'fix the bug',
|
||||
lastPrompt: 'now ship it',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-l1');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.lastPrompt).toBe('now ship it');
|
||||
// The upstream unified-service alias map (COD-160/161) folds the UUID-keyed
|
||||
// history row into the owning live session, so lastPrompt reaches the live row
|
||||
// above rather than surfacing as a separate uuid-LA entry.
|
||||
const hist = merged.find((m) => m.sessionId === 'uuid-LA');
|
||||
expect(hist).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to the workingDir transcript for lastPrompt when no uuid join exists (workingDir fallback)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-l2', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl2' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-other',
|
||||
workingDir: '/wl2',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'borrowed first',
|
||||
lastPrompt: 'borrowed last',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-l2');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.lastPrompt).toBe('borrowed last');
|
||||
});
|
||||
|
||||
it('uses the newest transcript per workingDir for the lastPrompt fallback (newest-wins)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'codeman-l3', status: 'working', claudeSessionId: 'uuid-missing', workingDir: '/wl3' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'uuid-old',
|
||||
workingDir: '/wl3',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'older first',
|
||||
lastPrompt: 'older last',
|
||||
},
|
||||
{
|
||||
sessionId: 'uuid-new',
|
||||
workingDir: '/wl3',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-02-01T00:00:00.000Z',
|
||||
firstPrompt: 'newer first',
|
||||
lastPrompt: 'newer last',
|
||||
},
|
||||
],
|
||||
});
|
||||
const live = merged.find((m) => m.sessionId === 'codeman-l3');
|
||||
expect(live).toBeDefined();
|
||||
expect(live!.lastPrompt).toBe('newer last');
|
||||
});
|
||||
|
||||
it('never overwrites a lastPrompt that already merged from the session own transcript (no overwrite)', () => {
|
||||
const merged = mergeUnifiedSessions({
|
||||
live: [{ id: 'self-luuid', status: 'working', claudeSessionId: 'self-luuid', workingDir: '/wl4' }],
|
||||
history: [
|
||||
{
|
||||
sessionId: 'self-luuid',
|
||||
workingDir: '/wl4',
|
||||
sizeBytes: 5000,
|
||||
lastModified: '2026-01-01T00:00:00.000Z',
|
||||
firstPrompt: 'own first',
|
||||
lastPrompt: 'own last',
|
||||
},
|
||||
{
|
||||
sessionId: 'sibling-uuid',
|
||||
workingDir: '/wl4',
|
||||
sizeBytes: 6000,
|
||||
lastModified: '2026-03-01T00:00:00.000Z',
|
||||
firstPrompt: 'sibling first',
|
||||
lastPrompt: 'sibling last',
|
||||
},
|
||||
],
|
||||
});
|
||||
const self = merged.find((m) => m.sessionId === 'self-luuid');
|
||||
expect(self).toBeDefined();
|
||||
expect(self!.lastPrompt).toBe('own last');
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndPaginate', () => {
|
||||
@@ -276,6 +375,14 @@ describe('filterAndPaginate', () => {
|
||||
{ sessionId: 's1', name: 'Alpha build', sources: ['live'], workingDir: '/repo/alpha' },
|
||||
{ sessionId: 's2', name: 'Beta', firstPrompt: 'fix the login bug', sources: ['history'], workingDir: '/repo/beta' },
|
||||
{ sessionId: 's3', name: 'Gamma', sources: ['persisted'], workingDir: '/srv/gamma' },
|
||||
{
|
||||
sessionId: 's4',
|
||||
name: 'Delta',
|
||||
firstPrompt: 'start the migration',
|
||||
lastPrompt: 'roll back the migration',
|
||||
sources: ['history'],
|
||||
workingDir: '/repo/delta',
|
||||
},
|
||||
];
|
||||
|
||||
it('filters by name (case-insensitive)', () => {
|
||||
@@ -289,18 +396,24 @@ describe('filterAndPaginate', () => {
|
||||
expect(filterAndPaginate(items, { q: '/srv/' }).sessions[0].sessionId).toBe('s3');
|
||||
});
|
||||
|
||||
it('filters by lastPrompt (COD-145)', () => {
|
||||
const r = filterAndPaginate(items, { q: 'roll back' });
|
||||
expect(r.total).toBe(1);
|
||||
expect(r.sessions[0].sessionId).toBe('s4');
|
||||
});
|
||||
|
||||
it('reports total as the pre-page filtered count', () => {
|
||||
const r = filterAndPaginate(items, { q: 'repo', limit: 1 });
|
||||
// both s1 and s2 have /repo/ workingDir
|
||||
expect(r.total).toBe(2);
|
||||
// s1, s2, and s4 all have /repo/ workingDir
|
||||
expect(r.total).toBe(3);
|
||||
expect(r.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('clamps limit to a max of 500', () => {
|
||||
const r = filterAndPaginate(items, { limit: 99999 });
|
||||
expect(r.sessions).toHaveLength(items.length);
|
||||
// clamp does not throw and returns all 3 (< 500)
|
||||
expect(r.total).toBe(3);
|
||||
// clamp does not throw and returns all items (< 500)
|
||||
expect(r.total).toBe(items.length);
|
||||
});
|
||||
|
||||
it('clamps limit to a min of 1', () => {
|
||||
@@ -312,7 +425,7 @@ describe('filterAndPaginate', () => {
|
||||
const page1 = filterAndPaginate(items, { offset: 0, limit: 2 });
|
||||
const page2 = filterAndPaginate(items, { offset: 2, limit: 2 });
|
||||
expect(page1.sessions.map((s) => s.sessionId)).toEqual(['s1', 's2']);
|
||||
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3']);
|
||||
expect(page2.sessions.map((s) => s.sessionId)).toEqual(['s3', 's4']);
|
||||
const overlap = page1.sessions
|
||||
.map((s) => s.sessionId)
|
||||
.filter((id) => page2.sessions.map((s2) => s2.sessionId).includes(id));
|
||||
|
||||
Reference in New Issue
Block a user