From 5ec71ace5a87bb147d2f58b7c5951aba3c6d8a68 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 21 Jun 2026 16:38:12 -0400 Subject: [PATCH] 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) --- src/services/unified-session-service.ts | 59 ++++++--- src/web/public/terminal-ui.js | 19 ++- src/web/routes/session-routes.ts | 67 ++++++++++ test/services/unified-session-service.test.ts | 123 +++++++++++++++++- 4 files changed, 245 insertions(+), 23 deletions(-) diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index ccd13976..5d4fa134 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -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(); const firstPromptByWorkingDir = new Map(); + // COD-145: lastPrompt rides the same backfill (build parallel indexes; never overwrite). + const lastPromptByUuid = new Map(); + const lastPromptByWorkingDir = new Map(); 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(); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 7cccbaa5..f309a31b 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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'); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 62551974..23eb0f00 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -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, }); } diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index 36fab474..f25faff7 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -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));