mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =
|
||||
date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) +
|
||||
' ' +
|
||||
date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false });
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user