From 1d395d5062554296abe676d693e2c08473bfe8ce Mon Sep 17 00:00:00 2001 From: Teigen Date: Fri, 24 Apr 2026 19:00:59 +0800 Subject: [PATCH] feat: improve Resume Conversation UX on mobile Default layout was a single nowrap row with path + date + size, which on narrow screens truncated both the first prompt and the directory suffix (where project names actually live). The /Users/ home shorthand was also never applied on macOS. Changes: - Title now uses 2-line clamp so more of the first prompt is visible. - Subtitle resolves workingDir against known cases: exact match shows "#caseName", subpath shows "#caseName/sub", otherwise falls back to basename. Case labels are styled distinctly. - Normalize both /home// and /Users// prefixes to "~/". - Each history item gets a "..." toggle that expands an in-place detail panel with the full prompt, full path, timestamp, size, and short session id. Collapses back on second click; clicking the card body still triggers resume. --- src/web/public/styles.css | 119 ++++++++++++++++++++++++++++++---- src/web/public/terminal-ui.js | 111 +++++++++++++++++++++++++++---- 2 files changed, 207 insertions(+), 23 deletions(-) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 8914eb14..d5758fb8 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2202,13 +2202,10 @@ body { .history-item { display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.55rem 0.8rem; + flex-direction: column; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.06); border-radius: 8px; - cursor: pointer; transition: all var(--transition-smooth); text-align: left; } @@ -2219,20 +2216,37 @@ body { box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } +.history-item.expanded { + border-color: rgba(59, 130, 246, 0.35); + background: rgba(255, 255, 255, 0.05); +} + +.history-item-main { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.55rem 0.8rem; + cursor: pointer; + border-radius: 8px; +} + .history-item-text { flex: 1; min-width: 0; display: flex; flex-direction: column; - gap: 0.15rem; + gap: 0.2rem; } .history-item-title { font-size: 0.8rem; color: var(--text); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + word-break: break-word; } .history-item-subtitle { @@ -2243,18 +2257,99 @@ body { white-space: nowrap; } +.history-item-subtitle.is-case { + color: #7aa7ff; + font-weight: 500; +} + .history-item-meta { font-size: 0.7rem; color: var(--text-muted); white-space: nowrap; + align-self: center; } -.history-item-size { - font-size: 0.7rem; +.history-item-expand { + appearance: none; + border: none; + background: transparent; color: var(--text-dim); - white-space: nowrap; - min-width: 45px; - text-align: right; + font-size: 1.1rem; + line-height: 1; + padding: 0.25rem 0.4rem; + cursor: pointer; + border-radius: 6px; + transition: background var(--transition-smooth), color var(--transition-smooth); + flex-shrink: 0; +} + +.history-item-expand:hover { + background: rgba(255, 255, 255, 0.08); + color: var(--text); +} + +.history-item.expanded .history-item-expand { + color: #7aa7ff; + transform: rotate(90deg); +} + +.history-item-detail { + padding: 0.5rem 0.8rem 0.7rem; + border-top: 1px dashed rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + gap: 0.4rem; + font-size: 0.72rem; + color: var(--text-muted); +} + +.history-item-detail[hidden] { + display: none; +} + +.history-detail-row { + display: flex; + gap: 0.5rem; + align-items: flex-start; + word-break: break-word; +} + +.history-detail-label { + color: var(--text-dim); + min-width: 46px; + flex-shrink: 0; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding-top: 0.1rem; +} + +.history-detail-value { + color: var(--text); + flex: 1; + min-width: 0; + white-space: pre-wrap; +} + +.history-detail-path { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + font-size: 0.68rem; + color: #a8b5c9; +} + +.history-detail-meta { + color: var(--text-dim); + font-size: 0.68rem; +} + +@media (max-width: 640px) { + .history-item-meta { + font-size: 0.65rem; + } + .history-item-main { + gap: 0.45rem; + padding: 0.6rem 0.7rem; + } } .history-show-more { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 78b9cc7a..7a49a5fc 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -845,8 +845,40 @@ Object.assign(CodemanApp.prototype, { return items; }, + /** + * Resolve workingDir to a case-aware short label. + * - Exact case path match → "#caseName" + * - workingDir under a case dir → "#caseName/subdir" + * - Otherwise → basename (e.g. "Claudeman") + */ + _resolveCaseLabel(workingDir, cases) { + if (!workingDir) return ''; + let best = null; + for (const c of cases || []) { + if (!c || !c.path) continue; + if (workingDir === c.path) { + return `#${c.name}`; + } + if (workingDir.startsWith(c.path + '/')) { + const len = c.path.length; + if (!best || len > best.len) { + best = { name: c.name, suffix: workingDir.slice(len), len }; + } + } + } + if (best) return `#${best.name}${best.suffix}`; + return workingDir.split('/').pop() || workingDir; + }, + + /** Normalize home prefixes to "~/" on both Linux and macOS */ + _shortenHomePath(p) { + return (p || '') + .replace(/^\/home\/[^/]+\//, '~/') + .replace(/^\/Users\/[^/]+\//, '~/'); + }, + /** Build a single history item DOM element */ - _buildHistoryItem(s) { + _buildHistoryItem(s, cases) { const size = s.sizeBytes < 1024 ? `${s.sizeBytes}B` @@ -858,12 +890,17 @@ Object.assign(CodemanApp.prototype, { date.toLocaleDateString('en', { month: 'short', day: 'numeric' }) + ' ' + date.toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit', hour12: false }); - const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); + const shortDir = this._shortenHomePath(s.workingDir); + const caseLabel = this._resolveCaseLabel(s.workingDir, cases); const item = document.createElement('div'); item.className = 'history-item'; item.title = s.workingDir; - item.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); + + // Main row: clickable surface that triggers resume + const mainRow = document.createElement('div'); + mainRow.className = 'history-item-main'; + mainRow.addEventListener('click', () => this.resumeHistorySession(s.sessionId, s.workingDir)); const textCol = document.createElement('div'); textCol.className = 'history-item-text'; @@ -874,7 +911,8 @@ Object.assign(CodemanApp.prototype, { const subtitleSpan = document.createElement('span'); subtitleSpan.className = 'history-item-subtitle'; - subtitleSpan.textContent = shortDir; + if (caseLabel.startsWith('#')) subtitleSpan.classList.add('is-case'); + subtitleSpan.textContent = caseLabel; textCol.append(titleSpan, subtitleSpan); @@ -882,11 +920,54 @@ Object.assign(CodemanApp.prototype, { metaSpan.className = 'history-item-meta'; metaSpan.textContent = timeStr; - const sizeSpan = document.createElement('span'); - sizeSpan.className = 'history-item-size'; - sizeSpan.textContent = size; + const expandBtn = document.createElement('button'); + expandBtn.className = 'history-item-expand'; + expandBtn.type = 'button'; + expandBtn.setAttribute('aria-label', 'Show details'); + expandBtn.setAttribute('aria-expanded', 'false'); + expandBtn.textContent = '⋯'; // ⋯ + + mainRow.append(textCol, metaSpan, expandBtn); + + // Detail panel: full prompt + full path, hidden by default + const detail = document.createElement('div'); + detail.className = 'history-item-detail'; + detail.hidden = true; + + const promptRow = document.createElement('div'); + promptRow.className = 'history-detail-row'; + const promptLabel = document.createElement('span'); + promptLabel.className = 'history-detail-label'; + promptLabel.textContent = 'Prompt'; + const promptText = document.createElement('span'); + promptText.className = 'history-detail-value history-detail-prompt'; + promptText.textContent = s.firstPrompt || '(no prompt captured)'; + promptRow.append(promptLabel, promptText); + + const pathRow = document.createElement('div'); + pathRow.className = 'history-detail-row'; + const pathLabel = document.createElement('span'); + pathLabel.className = 'history-detail-label'; + pathLabel.textContent = 'Path'; + const pathText = document.createElement('span'); + pathText.className = 'history-detail-value history-detail-path'; + pathText.textContent = shortDir; + pathRow.append(pathLabel, pathText); + + const metaRow = document.createElement('div'); + metaRow.className = 'history-detail-row history-detail-meta'; + metaRow.textContent = `${timeStr} · ${size} · ${s.sessionId.slice(0, 8)}`; + + detail.append(promptRow, pathRow, metaRow); + + expandBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + const expanded = item.classList.toggle('expanded'); + detail.hidden = !expanded; + expandBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + }); - item.append(textCol, metaSpan, sizeSpan); + item.append(mainRow, detail); return item; }, @@ -899,7 +980,15 @@ Object.assign(CodemanApp.prototype, { if (!container || !list) return; try { - const allSessions = await this._fetchHistorySessions(30); + // Load cases in parallel so subtitle can show "#caseName" labels. + // Prefer already-loaded this.cases to avoid an extra request. + const casesPromise = Array.isArray(this.cases) && this.cases.length > 0 + ? Promise.resolve(this.cases) + : fetch('/api/cases').then((r) => (r.ok ? r.json() : [])).catch(() => []); + const [allSessions, cases] = await Promise.all([ + this._fetchHistorySessions(30), + casesPromise, + ]); if (allSessions.length === 0) { container.style.display = 'none'; return; @@ -910,7 +999,7 @@ Object.assign(CodemanApp.prototype, { // Render initial items for (let i = 0; i < Math.min(initialCount, allSessions.length); i++) { - list.appendChild(this._buildHistoryItem(allSessions[i])); + list.appendChild(this._buildHistoryItem(allSessions[i], cases)); } // Add "Show More" button if there are more items @@ -920,7 +1009,7 @@ Object.assign(CodemanApp.prototype, { moreBtn.textContent = `Show ${allSessions.length - initialCount} more`; moreBtn.addEventListener('click', () => { for (let i = initialCount; i < allSessions.length; i++) { - list.insertBefore(this._buildHistoryItem(allSessions[i]), moreBtn); + list.insertBefore(this._buildHistoryItem(allSessions[i], cases), moreBtn); } moreBtn.remove(); });