From c9f37f262820e5c794b523bc2ccdc3ac5facc5b6 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 18 Jun 2026 11:59:01 -0400 Subject: [PATCH 1/5] 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. --- src/services/unified-session-service.ts | 3 + src/web/public/styles.css | 26 ++++++ src/web/public/terminal-ui.js | 90 ++++++++++++++++--- src/web/routes/session-routes.ts | 1 + test/services/unified-session-service.test.ts | 16 ++++ 5 files changed, 122 insertions(+), 14 deletions(-) diff --git a/src/services/unified-session-service.ts b/src/services/unified-session-service.ts index 16fb7d11..03b7c618 100644 --- a/src/services/unified-session-service.ts +++ b/src/services/unified-session-service.ts @@ -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; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bf0d3cdc..d05f4d96 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 9c550dc7..447dbdde 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -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} 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) { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 71b90770..8504dcbb 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1829,6 +1829,7 @@ export function registerSessionRoutes( sizeBytes: h.sizeBytes, lastModified: h.lastModified, firstPrompt: h.firstPrompt, + projectKey: h.projectKey, }); } } diff --git a/test/services/unified-session-service.test.ts b/test/services/unified-session-service.test.ts index 04073719..e08880c5 100644 --- a/test/services/unified-session-service.test.ts +++ b/test/services/unified-session-service.test.ts @@ -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', () => { From 65b609b6dba637839ac8bf87576f9cc40e128868 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 18 Jun 2026 12:28:14 -0400 Subject: [PATCH 2/5] COD-121 unified session list: persistent Session Manager modal (slice A, unit 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a header-reachable Session Manager so the complete session list is available mid-session, not only on the welcome screen. - index.html: always-on header button (.btn-session-manager) + #sessionManagerModal (mirrors the Away Digest modal) with a search box + results list. - panels-ui.js: openSessionManager()/closeSessionManager()/_loadSessionManagerList() — loads GET /api/sessions/unified (limit 200), renders via the unit-2 _buildHistoryItem (rich items, mode/LIVE badges, open->select / closed->resume), debounced search wired to the endpoint's q= param, empty/error states. A modal-scoped Escape listener closes it even when focus is in the search input; backdrop click and item click also close it. - app.js: closeSessionManager() added to the global Escape chain. - styles.css: modal + list styling (items reuse .history-item). Verified on an isolated beta instance (Playwright): the header button opens the modal, it lists 200 sessions from /api/sessions/unified, a no-match query issues ?q= to the server and yields 0 items, clearing restores the list, clicking an item closes the modal and routes resume/select, and Escape closes it. Gates: tsc 0, lint 0, frontend-syntax + public-asset format clean, 17 tests pass. --- src/web/public/app.js | 1 + src/web/public/index.html | 17 ++++++- src/web/public/panels-ui.js | 94 +++++++++++++++++++++++++++++++++++++ src/web/public/styles.css | 28 +++++++++++ 4 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index b23e29ff..37e8f411 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -827,6 +827,7 @@ class CodemanApp { this.closeAllPanels(); this.closeHelp(); if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory(); + this.closeSessionManager(); } // Option/Alt session navigation uses physical key CODES, not e.key, so macOS diff --git a/src/web/public/index.html b/src/web/public/index.html index 8de0fb3f..e55f6074 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -118,7 +118,7 @@ - + + + + + + - + +