diff --git a/src/mux-interface.ts b/src/mux-interface.ts index cc3d5e60..5934bccc 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -75,6 +75,8 @@ export interface RespawnPaneOptions { claudeMode?: ClaudeMode; allowedTools?: string; openCodeConfig?: OpenCodeConfig; + /** Resume a previous Claude conversation when respawning */ + resumeSessionId?: string; } /** diff --git a/src/session.ts b/src/session.ts index 8e6051ab..4402dfba 100644 --- a/src/session.ts +++ b/src/session.ts @@ -912,6 +912,7 @@ export class Session extends EventEmitter { claudeMode: this._claudeMode, allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, + resumeSessionId: this._resumeSessionId, }); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9dff494f..34c90eee 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -206,11 +206,18 @@ function buildSpawnCommand(options: { // Validate model to prevent command injection const safeModel = options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined; const modelFlag = safeModel ? ` --model ${safeModel}` : ''; - // Use --resume to restore a previous conversation, otherwise --session-id for new sessions + // Use --resume to restore a previous conversation, otherwise --session-id for new sessions. + // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.), + // fall back to a new session with --session-id so the pane doesn't die. const safeResumeId = options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined; - const sessionFlag = safeResumeId ? ` --resume "${safeResumeId}"` : ` --session-id "${options.sessionId}"`; - return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)}${sessionFlag}${modelFlag}`; + const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools); + if (safeResumeId) { + const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}`; + const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`; + return `${resumeCmd} || ${fallbackCmd}`; + } + return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}`; } if (options.mode === 'opencode') { return buildOpenCodeCommand(options.openCodeConfig); @@ -622,7 +629,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * preserving the session and its scrollback buffer. */ async respawnPane(options: RespawnPaneOptions): Promise { - const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; + const { + sessionId, + workingDir, + mode, + niceConfig, + model, + claudeMode, + allowedTools, + openCodeConfig, + resumeSessionId, + } = options; const session = this.sessions.get(sessionId); if (!session) return null; const muxName = session.muxName; @@ -658,6 +675,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { claudeMode, allowedTools, openCodeConfig, + resumeSessionId, }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); diff --git a/src/web/public/app.js b/src/web/public/app.js index 6985d9e0..8eccc6e3 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1217,6 +1217,8 @@ class CodemanApp { } async resumeHistorySession(sessionId, workingDir) { + // Close the run mode menu if open + document.getElementById('runModeMenu')?.classList.remove('active'); try { this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Resuming conversation ${sessionId.slice(0, 8)}...\x1b[0m`); @@ -4242,6 +4244,8 @@ class CodemanApp { this._runMode = mode; try { localStorage.setItem('codeman_runMode', mode); } catch {} this._applyRunMode(); + // Sync to server for cross-device persistence + this._apiPut('/api/settings', { runMode: mode }).catch(() => {}); // Close menu document.getElementById('runModeMenu')?.classList.remove('active'); } @@ -4255,8 +4259,9 @@ class CodemanApp { menu.querySelectorAll('.run-mode-option').forEach(btn => { btn.classList.toggle('selected', btn.dataset.mode === this.runMode); }); - // Close on click outside + // Load history sessions when menu opens if (menu.classList.contains('active')) { + this._loadRunModeHistory(); const close = (ev) => { if (!menu.contains(ev.target)) { menu.classList.remove('active'); @@ -4267,6 +4272,68 @@ class CodemanApp { } } + async _loadRunModeHistory() { + const container = document.getElementById('runModeHistory'); + if (!container) return; + container.innerHTML = '
Loading...
'; + + try { + const res = await fetch('/api/history/sessions'); + const data = await res.json(); + const sessions = data.sessions || []; + + if (sessions.length === 0) { + container.innerHTML = '
No history
'; + return; + } + + // Deduplicate: up to 2 per dir, max 10 total + const byDir = new Map(); + for (const s of sessions) { + if (!byDir.has(s.workingDir)) byDir.set(s.workingDir, []); + byDir.get(s.workingDir).push(s); + } + const items = []; + for (const [, group] of byDir) { + items.push(...group.slice(0, 2)); + } + items.sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); + const display = items.slice(0, 10); + + // Build items using DOM API for reliable mobile touch handling + container.replaceChildren(); + for (const s of display) { + 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 }); + const shortDir = s.workingDir.replace(/^\/home\/[^/]+\//, '~/'); + + const btn = document.createElement('button'); + btn.className = 'run-mode-option'; + btn.title = s.workingDir; + btn.dataset.sessionId = s.sessionId; + btn.dataset.workingDir = s.workingDir; + + const dirSpan = document.createElement('span'); + dirSpan.className = 'hist-dir'; + dirSpan.textContent = shortDir; + + const metaSpan = document.createElement('span'); + metaSpan.className = 'hist-meta'; + metaSpan.textContent = timeStr; + + btn.append(dirSpan, metaSpan); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.resumeHistorySession(s.sessionId, s.workingDir); + }); + container.appendChild(btn); + } + } catch (err) { + container.innerHTML = '
Failed to load
'; + } + } + _applyRunMode() { const mode = this.runMode; const runBtn = document.getElementById('runBtn'); @@ -5593,6 +5660,9 @@ class CodemanApp { // ═══════════════════════════════════════════════════════════════ loadRespawnPresets() { + // Custom presets: prefer server-synced cache, fall back to legacy localStorage key + const serverCache = this._serverRespawnPresets; + if (serverCache) return [...BUILTIN_RESPAWN_PRESETS, ...serverCache]; const saved = localStorage.getItem('codeman-respawn-presets'); const custom = saved ? JSON.parse(saved) : []; return [...BUILTIN_RESPAWN_PRESETS, ...custom]; @@ -5601,7 +5671,11 @@ class CodemanApp { saveRespawnPresets(presets) { // Only save custom presets (not built-in) const custom = presets.filter(p => !p.builtIn); + // Update local cache + legacy localStorage + this._serverRespawnPresets = custom; localStorage.setItem('codeman-respawn-presets', JSON.stringify(custom)); + // Persist to server (cross-device sync) + this._apiPut('/api/settings', { respawnPresets: custom }).catch(() => {}); } renderPresetDropdown() { @@ -7760,7 +7834,7 @@ class CodemanApp { const settings = settingsPromise ? await settingsPromise : await fetch('/api/settings').then(r => r.ok ? r.json() : null); if (settings) { // Extract notification prefs before merging app settings - const { notificationPreferences, voiceSettings, ...appSettings } = settings; + const { notificationPreferences, voiceSettings, respawnPresets, runMode, ...appSettings } = settings; // Filter out display settings — these are device-specific (mobile vs desktop) // and should not be synced from the server to avoid overriding mobile defaults. // NOTE: Feature toggles (subagentTrackingEnabled, imageWatcherEnabled, ralphTrackerEnabled) @@ -7805,6 +7879,30 @@ class CodemanApp { } } + // Sync respawn presets from server (server is source of truth) + if (respawnPresets && Array.isArray(respawnPresets)) { + this._serverRespawnPresets = respawnPresets; + // Also update localStorage for offline access + localStorage.setItem('codeman-respawn-presets', JSON.stringify(respawnPresets)); + } else { + // Migration: push existing localStorage presets to server + const localPresets = localStorage.getItem('codeman-respawn-presets'); + if (localPresets) { + const parsed = JSON.parse(localPresets); + if (parsed.length > 0) { + this._serverRespawnPresets = parsed; + this._apiPut('/api/settings', { respawnPresets: parsed }).catch(() => {}); + } + } + } + + // Sync run mode from server + if (runMode) { + this.runMode = runMode; + try { localStorage.setItem('codeman_runMode', runMode); } catch {} + this._applyRunMode(); + } + return merged; } } catch (err) { diff --git a/src/web/public/index.html b/src/web/public/index.html index 70fe4d75..9344dbe7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -330,6 +330,9 @@ +
+
Recent Sessions
+
diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 1eda8bb1..d1930c87 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -626,12 +626,20 @@ html.mobile-init .file-browser-panel { bottom: 100%; left: 0; margin-bottom: 6px; - min-width: 140px; + min-width: 160px; + max-width: 80vw; } .run-mode-option { padding: 10px 12px; font-size: 0.8rem; + cursor: pointer; + -webkit-tap-highlight-color: rgba(255, 255, 255, 0.1); + } + + .run-mode-history { + -webkit-overflow-scrolling: touch; + touch-action: manipulation; } /* Stop button - visible on mobile, icon-only */ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4827785b..04d7fe41 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2080,7 +2080,7 @@ body { text-align: center; } -/* History sessions panel */ +/* History sessions panel (welcome overlay) */ .history-sessions { margin-top: 1.5rem; width: 100%; @@ -2324,7 +2324,8 @@ body { border-radius: 6px; padding: 4px; z-index: 1000; - min-width: 150px; + min-width: 180px; + max-width: 300px; box-shadow: 0 4px 12px rgba(0,0,0,0.4); } .run-mode-menu.active { @@ -2361,6 +2362,40 @@ body { .run-mode-dot.claude { background: #3b82f6; } .run-mode-dot.opencode { background: #10b981; } +.run-mode-sep { + height: 1px; + background: var(--border); + margin: 4px 0; +} +.run-mode-header { + padding: 4px 10px 2px; + font-size: 0.72em; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 0.04em; + font-weight: 600; +} +.run-mode-history { + max-height: 200px; + overflow-y: auto; +} +.run-mode-option .hist-dir { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.run-mode-option .hist-meta { + font-size: 0.85em; + color: var(--text-muted); + white-space: nowrap; +} +.run-mode-hist-empty { + padding: 6px 10px; + color: var(--text-muted); + font-style: italic; +} + /* Shell Button - Special styling */ .btn-toolbar.btn-shell { background: linear-gradient(135deg, #1a2e1a 0%, #234023 100%); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 804c816a..75f6d23b 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -965,8 +965,32 @@ export function registerSessionRoutes( const filePath = join(projPath, entry); const fileStat = await fs.stat(filePath).catch(() => null); if (!fileStat) continue; - // Skip tiny files (empty or just init) - if (fileStat.size < 500) continue; + // Skip files too small to contain real conversation (metadata-only sessions + // like file-history-snapshot entries are typically < 4KB) + if (fileStat.size < 4000) continue; + + // Quick content check: verify actual conversation data exists. + // Sessions with only file-history-snapshot or hook_progress entries have + // no "user"/"assistant" messages and will fail claude --resume. + // Files > 50KB are almost certainly real conversations (skip the read). + if (fileStat.size < 50000) { + try { + const fd = await fs.open(filePath, 'r'); + const buf = Buffer.alloc(16384); + const { bytesRead } = await fd.read(buf, 0, 16384, 0); + await fd.close(); + const head = buf.toString('utf8', 0, bytesRead); + if ( + !head.includes('"type":"user"') && + !head.includes('"type":"assistant"') && + !head.includes('"type":"summary"') + ) { + continue; // No conversation content — skip + } + } catch { + continue; + } + } results.push({ sessionId, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 6922dc15..2110d00b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -321,6 +321,30 @@ export const SettingsUpdateSchema = z insertMode: z.string().max(20).optional(), }) .optional(), + // Run mode preference (cross-device sync) + runMode: z.string().max(20).optional(), + // Custom respawn presets (cross-device sync, replaces localStorage-only storage) + respawnPresets: z + .array( + z.object({ + id: z.string().max(100), + name: z.string().max(100), + config: z.object({ + idleTimeoutMs: z.number().optional(), + updatePrompt: z.string().max(5000).optional(), + interStepDelayMs: z.number().optional(), + sendClear: z.boolean().optional(), + sendInit: z.boolean().optional(), + kickstartPrompt: z.string().max(5000).optional(), + autoAcceptPrompts: z.boolean().optional(), + }), + durationMinutes: z.number().optional(), + builtIn: z.boolean().optional(), + createdAt: z.number().optional(), + }) + ) + .max(20) + .optional(), }) .strict(); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 7d9a45c9..4ef1c921 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -75,11 +75,7 @@ describe('session-routes', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.success).toBe(true); - expect(harness.ctx.cleanupSession).toHaveBeenCalledWith( - harness.ctx._sessionId, - true, - 'user_delete', - ); + expect(harness.ctx.cleanupSession).toHaveBeenCalledWith(harness.ctx._sessionId, true, 'user_delete'); }); it('returns error for unknown session', async () => { @@ -123,10 +119,7 @@ describe('session-routes', () => { expect(body.success).toBe(true); expect(body.name).toBe('new-name'); expect(harness.ctx.persistSessionState).toHaveBeenCalled(); - expect(harness.ctx.broadcast).toHaveBeenCalledWith( - 'session:updated', - expect.anything(), - ); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:updated', expect.anything()); }); it('returns error for unknown session', async () => { @@ -462,4 +455,109 @@ describe('session-routes', () => { expect(body.success).toBe(true); }); }); + + // ========== GET /api/history/sessions ========== + + describe('GET /api/history/sessions', () => { + it('returns sessions array', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toHaveProperty('sessions'); + expect(Array.isArray(body.sessions)).toBe(true); + }); + + it('sessions have required fields', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions', + }); + const body = JSON.parse(res.body); + for (const session of body.sessions) { + expect(session).toHaveProperty('sessionId'); + expect(session).toHaveProperty('workingDir'); + expect(session).toHaveProperty('projectKey'); + expect(session).toHaveProperty('sizeBytes'); + expect(session).toHaveProperty('lastModified'); + // sessionId must be a valid UUID + expect(session.sessionId).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/); + } + }); + + it('sessions are sorted by lastModified descending', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions', + }); + const body = JSON.parse(res.body); + const dates = body.sessions.map((s: { lastModified: string }) => new Date(s.lastModified).getTime()); + for (let i = 1; i < dates.length; i++) { + expect(dates[i - 1]).toBeGreaterThanOrEqual(dates[i]); + } + }); + + it('returns at most 50 sessions', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/history/sessions', + }); + const body = JSON.parse(res.body); + expect(body.sessions.length).toBeLessThanOrEqual(50); + }); + }); + + // ========== POST /api/sessions (with resumeSessionId) ========== + + describe('POST /api/sessions with resumeSessionId', () => { + it('creates session with valid resumeSessionId', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { + name: 'resume-test', + mode: 'claude', + workingDir: process.env.HOME || '/tmp', + resumeSessionId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.session).toBeDefined(); + }); + + it('rejects invalid resumeSessionId format', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { + name: 'bad-resume', + mode: 'claude', + workingDir: process.env.HOME || '/tmp', + resumeSessionId: 'not-a-uuid', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('creates session without resumeSessionId (optional field)', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { + name: 'no-resume', + mode: 'claude', + workingDir: process.env.HOME || '/tmp', + }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + }); });