COD-121 unified session list: welcome list frontend (slice A, unit 2)

Backs the welcome-screen "Resume Conversation" list with the new
GET /api/sessions/unified endpoint instead of /api/history/sessions, so it
shows the COMPLETE set (live + persisted + non-Claude + closed history)
newest-first with richer context, rather than only Claude transcripts.

- terminal-ui.js: new _fetchUnifiedSessions(); loadHistorySessions() now uses
  it. _buildHistoryItem upgraded to the unified shape (kept backward-compatible
  with the folder-modal's old shape): title = name || firstPrompt || dir; a
  mode badge + a LIVE badge (sources includes 'live'); timestamp from
  lastActivityAt (falls back to lastModified); size only when present; detail
  panel + "View all in this folder" preserved (gated on projectKey). Resume
  branches: an open live session selects its tab, a closed one resumes.
- unified-session-service.ts + endpoint: pass projectKey through the history
  source so the folder drill-down survives.
- styles.css: .history-item-badges / -badge / -badge-live pills.

Verified: tsc 0, lint 0, frontend-syntax + public-asset format clean, service
tests 13/13 (+projectKey), route tests 4/4. Playwright on an isolated beta:
the welcome list renders real items from /api/sessions/unified, and the
renderer produces the tab-name title + codex mode badge + visible LIVE badge,
omits LIVE on closed items, keeps "View all in folder", and routes resume
correctly (open->select tab, closed->resume). Persistent panel + live SSE
status are later units.
This commit is contained in:
Aamer Akhter
2026-07-12 12:19:10 -04:00
parent 3f23621f8d
commit c9f37f2628
5 changed files with 122 additions and 14 deletions
+3
View File
@@ -29,6 +29,7 @@ export type UnifiedSessionItem = {
claudeSessionId?: string;
firstPrompt?: string;
sizeBytes?: number;
projectKey?: string;
remote?: boolean;
sources: string[];
stats?: { memoryMB: number; cpuPercent: number };
@@ -76,6 +77,7 @@ export type HistoryInput = {
sizeBytes: number;
lastModified: string;
firstPrompt?: string;
projectKey?: string;
};
/** Mux process-stat view. */
@@ -147,6 +149,7 @@ export function mergeUnifiedSessions(sources: UnifiedSources): UnifiedSessionIte
overwrite(item, 'workingDir', h.workingDir);
overwrite(item, 'sizeBytes', h.sizeBytes);
overwrite(item, 'firstPrompt', h.firstPrompt);
overwrite(item, 'projectKey', h.projectKey);
const ms = Date.parse(h.lastModified);
if (!Number.isNaN(ms) && item.lastActivityAt === undefined) item.lastActivityAt = ms;
}
+26
View File
@@ -2822,6 +2822,32 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
font-weight: 500;
}
/* Badge row (mode + LIVE pills) sits between the title and subtitle (COD-121). */
.history-item-badges {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
}
.history-item-badge {
font-size: 0.55rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
line-height: 1;
padding: 0.12rem 0.35rem;
border-radius: 0.35rem;
background: var(--bg-hover);
color: var(--text-muted);
white-space: nowrap;
}
.history-item-badge-live {
background: color-mix(in srgb, var(--green) 22%, transparent);
color: var(--green);
}
.history-item-meta {
font-size: 0.7rem;
color: var(--text-muted);
+73 -11
View File
@@ -1127,6 +1127,19 @@ Object.assign(CodemanApp.prototype, {
return items;
},
/**
* Fetch the unified session list (live + persisted + non-Claude + closed
* history), already de-duplicated and sorted newest-first by the backend
* (`GET /api/sessions/unified`, COD-121). No client-side grouping needed.
* @param {number} [limit=60] max sessions to request
* @returns {Promise<Array>} unified session items, most recent first
*/
async _fetchUnifiedSessions(limit = 60) {
const res = await fetch('/api/sessions/unified?limit=' + limit);
const data = await res.json();
return data.data?.sessions || [];
},
/**
* Resolve workingDir to a case-aware short label.
* - Exact case path match → "#caseName"
@@ -1168,42 +1181,87 @@ Object.assign(CodemanApp.prototype, {
*/
_buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false;
const size =
s.sizeBytes < 1024
// Size: only render when a numeric byte count is present (unified items
// backed solely by a live/persisted source may omit it).
const hasSize = typeof s.sizeBytes === 'number';
const size = !hasSize
? ''
: s.sizeBytes < 1024
? `${s.sizeBytes}B`
: s.sizeBytes < 1048576
? `${(s.sizeBytes / 1024).toFixed(0)}K`
: `${(s.sizeBytes / 1048576).toFixed(1)}M`;
const date = new Date(s.lastModified);
const timeStr =
// Timestamp: unified shape carries lastActivityAt (ms epoch); the older
// folder-modal/history shape carries an ISO lastModified string. Prefer ms,
// fall back to parsing the string, and omit entirely when neither is valid.
const tsMs =
typeof s.lastActivityAt === 'number'
? s.lastActivityAt
: s.lastModified
? Date.parse(s.lastModified)
: NaN;
let timeStr = '';
if (!Number.isNaN(tsMs)) {
const date = new Date(tsMs);
timeStr =
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
' ' +
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
}
const shortDir = this._shortenHomePath(s.workingDir);
const caseLabel = this._resolveCaseLabel(s.workingDir, cases);
const isLive = Array.isArray(s.sources) && s.sources.includes('live');
const item = document.createElement('div');
item.className = 'history-item';
item.title = s.workingDir;
item.title = s.workingDir || '';
// Main row: clickable surface that triggers resume
// Main row: clickable surface that triggers resume (or focuses the live tab).
const mainRow = document.createElement('div');
mainRow.className = 'history-item-main';
mainRow.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir));
mainRow.addEventListener('click', () => {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.sessionId, s.workingDir || '');
}
});
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;
titleSpan.textContent = s.name || s.firstPrompt || shortDir;
// Badge row: mode (claude/codex/opencode/gemini/shell) + a LIVE pill.
const badgeRow = document.createElement('div');
badgeRow.className = 'history-item-badges';
if (s.mode) {
const modeBadge = document.createElement('span');
modeBadge.className = 'history-item-badge history-item-badge-mode';
modeBadge.textContent = s.mode;
badgeRow.appendChild(modeBadge);
}
if (isLive) {
const liveBadge = document.createElement('span');
liveBadge.className = 'history-item-badge history-item-badge-live';
liveBadge.textContent = 'LIVE';
badgeRow.appendChild(liveBadge);
}
const subtitleSpan = document.createElement('span');
subtitleSpan.className = 'history-item-subtitle';
if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case');
subtitleSpan.textContent = caseLabel;
textCol.append(titleSpan, subtitleSpan);
textCol.append(titleSpan);
if (badgeRow.childElementCount > 0) textCol.append(badgeRow);
textCol.append(subtitleSpan);
const metaSpan = document.createElement('span');
metaSpan.className = 'history-item-meta';
@@ -1245,7 +1303,11 @@ Object.assign(CodemanApp.prototype, {
const metaRow = document.createElement('div');
metaRow.className = 'history-detail-row history-detail-meta';
metaRow.textContent = `${timeStr} · ${size} · ${s.sessionId.slice(0, 8)}`;
const metaParts = [];
if (timeStr) metaParts.push(timeStr);
if (hasSize) metaParts.push(size);
metaParts.push(s.sessionId.slice(0, 8));
metaRow.textContent = metaParts.join(' · ');
detail.append(promptRow, pathRow, metaRow);
@@ -1290,7 +1352,7 @@ Object.assign(CodemanApp.prototype, {
? Promise.resolve(this.cases)
: fetch('/api/cases').then((r) => (r.ok ? r.json() : null)).then((d) => d?.data || []).catch(() => []);
const [allSessions, cases] = await Promise.all([
this._fetchHistorySessions(30),
this._fetchUnifiedSessions(60),
casesPromise,
]);
if (allSessions.length === 0) {
+1
View File
@@ -1829,6 +1829,7 @@ export function registerSessionRoutes(
sizeBytes: h.sizeBytes,
lastModified: h.lastModified,
firstPrompt: h.firstPrompt,
projectKey: h.projectKey,
});
}
}
@@ -147,6 +147,22 @@ describe('mergeUnifiedSessions', () => {
expect(merged).toHaveLength(1);
expect(merged[0].lastActivityAt).toBe(new Date('2026-01-01T00:00:00.000Z').getTime());
});
it('surfaces projectKey from a history input onto the merged item', () => {
const merged = mergeUnifiedSessions({
history: [
{
sessionId: 'h',
workingDir: '/w',
sizeBytes: 5000,
lastModified: '2026-01-01T00:00:00.000Z',
projectKey: '-repo-alpha',
},
],
});
expect(merged).toHaveLength(1);
expect(merged[0].projectKey).toBe('-repo-alpha');
});
});
describe('filterAndPaginate', () => {