diff --git a/public/history.ts b/public/history.ts new file mode 100644 index 0000000..0955220 --- /dev/null +++ b/public/history.ts @@ -0,0 +1,109 @@ +/** + * public/history.ts β€” resume a past Claude Code session (O2). + * + * A πŸ•˜ toolbar button opens a list of past sessions (GET /sessions); clicking + * "Resume" opens a new tab in that project's directory and runs + * `claude --resume `. + */ + +export interface HistoryHooks { + resume: (cwd: string, id: string) => void +} + +interface HistorySession { + id: string + cwd: string + project: string + mtimeMs: number + preview: string +} + +function relTime(ms: number): string { + const s = Math.max(0, (Date.now() - ms) / 1000) + if (s < 60) return 'just now' + if (s < 3600) return `${Math.floor(s / 60)}m ago` + if (s < 86400) return `${Math.floor(s / 3600)}h ago` + return `${Math.floor(s / 86400)}d ago` +} + +export function mountHistory(toolbar: HTMLElement, hooks: HistoryHooks): void { + const overlay = document.createElement('div') + overlay.id = 'historymodal' + overlay.style.display = 'none' + const card = document.createElement('div') + card.className = 'hist-card' + overlay.appendChild(card) + document.body.appendChild(overlay) + + const hide = (): void => { + overlay.style.display = 'none' + } + overlay.addEventListener('click', (e) => { + if (e.target === overlay) hide() + }) + + const open = async (): Promise => { + card.replaceChildren() + const title = document.createElement('div') + title.className = 'hist-title' + title.textContent = 'Resume a Claude session' + card.appendChild(title) + const status = document.createElement('div') + status.className = 'hist-empty' + status.textContent = 'Loading…' + card.appendChild(status) + overlay.style.display = 'flex' + + let sessions: HistorySession[] + try { + const res = await fetch('/sessions') + sessions = (await res.json()) as HistorySession[] + } catch { + status.textContent = 'Failed to load sessions.' + return + } + if (sessions.length === 0) { + status.textContent = 'No past Claude sessions found.' + return + } + status.remove() + + for (const s of sessions) { + const row = document.createElement('div') + row.className = 'hist-row' + + const main = document.createElement('div') + main.className = 'hist-main' + const proj = document.createElement('div') + proj.className = 'hist-proj' + proj.textContent = `${s.project} Β· ${relTime(s.mtimeMs)}` + const prev = document.createElement('div') + prev.className = 'hist-prev' + prev.textContent = s.preview || '(no preview)' + prev.title = s.cwd + main.append(proj, prev) + + const btn = document.createElement('button') + btn.className = 'hist-resume' + btn.textContent = 'Resume' + btn.addEventListener('click', () => { + hooks.resume(s.cwd, s.id) + hide() + }) + + row.append(main, btn) + card.appendChild(row) + } + } + + const toggle = document.createElement('button') + toggle.className = 'toolbtn' + toggle.textContent = 'πŸ•˜' + toggle.title = 'Resume a past Claude session' + toggle.setAttribute('aria-label', 'Session history') + toggle.addEventListener('click', () => { + if (overlay.style.display === 'none') void open() + else hide() + }) + toolbar.appendChild(toggle) +} diff --git a/public/main.ts b/public/main.ts index 0361269..b1970c2 100644 --- a/public/main.ts +++ b/public/main.ts @@ -17,6 +17,7 @@ import { mountSearch } from './search.js' import { mountQrConnect } from './qr.js' import { mountSettings, loadSettings, type Settings } from './settings.js' import { mountDashboard } from './dashboard.js' +import { mountHistory } from './history.js' const paneHost = document.getElementById('term') const tabs = document.getElementById('tabs') @@ -51,6 +52,9 @@ mountDashboard(toolbar, { snapshot: () => app.snapshot(), focus: (idx) => app.focusTab(idx), }) +mountHistory(toolbar, { + resume: (cwd, id) => app.newTabForResume(cwd, id), +}) mountQrConnect(toolbar) // PWA: register the service worker (installable + offline shell, M4). diff --git a/public/style.css b/public/style.css index 46b45ff..7ac30df 100644 --- a/public/style.css +++ b/public/style.css @@ -253,6 +253,73 @@ html, body { font-weight: 600; } +/* History / resume modal (O2) */ +#historymodal { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 56px; + background: rgba(0, 0, 0, 0.6); +} +.hist-card { + background: #222; + border: 1px solid #3a3a3a; + border-radius: 10px; + min-width: 360px; + max-width: 92vw; + max-height: 72vh; + overflow-y: auto; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} +.hist-title { + padding: 12px 16px; + font-weight: 600; + color: #fff; + border-bottom: 1px solid #3a3a3a; +} +.hist-empty { + padding: 16px; + color: #aaa; +} +.hist-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-bottom: 1px solid #2a2a2a; +} +.hist-main { + flex: 1 1 auto; + min-width: 0; +} +.hist-proj { + color: #fff; + font-size: 13px; +} +.hist-prev { + color: #aaa; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hist-resume { + flex: none; + background: #2ea043; + color: #fff; + border: none; + border-radius: 5px; + padding: 6px 14px; + cursor: pointer; + font: inherit; +} +.hist-resume:hover { + background: #3fb950; +} + .tab { display: flex; align-items: center; diff --git a/public/tabs.ts b/public/tabs.ts index 1b4e423..c886428 100644 --- a/public/tabs.ts +++ b/public/tabs.ts @@ -151,7 +151,12 @@ export class TabApp { /* ── tab lifecycle ───────────────────────────────────────────── */ - private addEntry(sessionId: string | null, customTitle: string | null, cwd?: string): TabEntry { + private addEntry( + sessionId: string | null, + customTitle: string | null, + cwd?: string, + initialInput?: string, + ): TabEntry { const entry: TabEntry = { session: null as unknown as TerminalSession, customTitle, @@ -162,6 +167,7 @@ export class TabApp { entry.session = new TerminalSession({ sessionId, ...(cwd !== undefined ? { cwd } : {}), + ...(initialInput !== undefined ? { initialInput } : {}), onSessionId: () => this.persist(), // onActivity only fires for hidden (inactive) panes (see TerminalSession). onActivity: () => { @@ -198,6 +204,14 @@ export class TabApp { this.activate(this.tabs.length - 1) } + /** O2: open a new tab in `cwd` and run `claude --resume `. */ + newTabForResume(cwd: string, sessionId: string): void { + this.addEntry(null, null, cwd || undefined, `claude --resume ${sessionId}`) + this.persist() + this.rebuild() + this.activate(this.tabs.length - 1) + } + activate(i: number): void { if (i < 0 || i >= this.tabs.length) return this.maybeAskNotify() // first switch is a user gesture β€” request notif permission diff --git a/public/terminal-session.ts b/public/terminal-session.ts index e58709d..591a57c 100644 --- a/public/terminal-session.ts +++ b/public/terminal-session.ts @@ -54,6 +54,8 @@ export interface TerminalSessionOpts { onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void /** Optional: spawn a NEW session in this directory ("new tab here", M6). */ cwd?: string + /** Optional: type this once the new session's shell is ready (O2 resume). */ + initialInput?: string } export class TerminalSession { @@ -69,6 +71,8 @@ export class TerminalSession { private readonly onStatus: ((status: SessionStatus) => void) | undefined private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined private readonly spawnCwd: string | undefined + private readonly initialInput: string | undefined + private initialSent = false private statusValue: SessionStatus = 'connecting' private claudeStatusValue: ClaudeStatus = 'unknown' private cwdValue: string | null = null @@ -94,6 +98,7 @@ export class TerminalSession { this.onStatus = opts.onStatus this.onClaudeStatus = opts.onClaudeStatus this.spawnCwd = opts.cwd + this.initialInput = opts.initialInput this.el = document.createElement('div') this.el.className = 'term-pane' @@ -238,6 +243,12 @@ export class TerminalSession { this.onSessionId(msg.sessionId) const dims = this.safefit() if (dims !== null) this.sendResize(dims.cols, dims.rows) + // O2: once the shell is up, type the resume command (only on first attach). + if (this.initialInput !== undefined && !this.initialSent) { + this.initialSent = true + const cmd = this.initialInput + setTimeout(() => this.send(cmd), 700) + } break } case 'output': { diff --git a/src/http/history.ts b/src/http/history.ts new file mode 100644 index 0000000..48b3fea --- /dev/null +++ b/src/http/history.ts @@ -0,0 +1,121 @@ +/** + * src/http/history.ts (O2) β€” list past Claude Code sessions for the resume browser. + * + * Reads ~/.claude/projects//.jsonl. Each session's first + * user message is the preview and its `cwd` is the project directory (so resume + * can `claude --resume ` in the right place). Read-only; best-effort. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export interface HistorySession { + id: string; + cwd: string; + project: string; // last path segment, for display + mtimeMs: number; + preview: string; +} + +/** Extract cwd + the first real user prompt from a session jsonl (pure, testable). */ +export function parseSessionMeta(jsonlText: string): { cwd: string | null; preview: string } { + let cwd: string | null = null; + let preview = ''; + + for (const line of jsonlText.split('\n')) { + const trimmed = line.trim(); + if (trimmed === '') continue; + let obj: Record; + try { + obj = JSON.parse(trimmed) as Record; + } catch { + continue; + } + + if (cwd === null && typeof obj['cwd'] === 'string') cwd = obj['cwd']; + + if (preview === '' && obj['type'] === 'user') { + const msg = obj['message']; + if (msg !== null && typeof msg === 'object') { + const content = (msg as Record)['content']; + if (typeof content === 'string') { + preview = content; + } else if (Array.isArray(content)) { + for (const part of content) { + if (part !== null && typeof part === 'object') { + const p = part as Record; + if (p['type'] === 'text' && typeof p['text'] === 'string') { + preview = p['text']; + break; + } + } + } + } + } + } + + if (cwd !== null && preview !== '') break; + } + + return { cwd, preview: preview.replace(/\s+/g, ' ').trim().slice(0, 120) }; +} + +function readHead(file: string, max: number): string { + try { + const fd = fs.openSync(file, 'r'); + try { + const buf = Buffer.alloc(max); + const n = fs.readSync(fd, buf, 0, max, 0); + return buf.subarray(0, n).toString('utf8'); + } finally { + fs.closeSync(fd); + } + } catch { + return ''; + } +} + +/** Most-recently-modified Claude Code sessions across all projects. */ +export function listSessions(limit = 50): HistorySession[] { + const root = path.join(os.homedir(), '.claude', 'projects'); + let dirs: string[]; + try { + dirs = fs.readdirSync(root); + } catch { + return []; + } + + const files: Array<{ id: string; file: string; mtimeMs: number }> = []; + for (const dir of dirs) { + const dirPath = path.join(root, dir); + let names: string[]; + try { + names = fs.readdirSync(dirPath); + } catch { + continue; + } + for (const name of names) { + if (!name.endsWith('.jsonl')) continue; + try { + const st = fs.statSync(path.join(dirPath, name)); + files.push({ + id: name.slice(0, -'.jsonl'.length), + file: path.join(dirPath, name), + mtimeMs: st.mtimeMs, + }); + } catch { + // skip unreadable + } + } + } + + files.sort((a, b) => b.mtimeMs - a.mtimeMs); + + return files.slice(0, limit).map((f) => { + const meta = parseSessionMeta(readHead(f.file, 256 * 1024)); + const cwd = meta.cwd ?? ''; + const project = cwd !== '' ? (cwd.split('/').filter(Boolean).pop() ?? cwd) : 'unknown'; + return { id: f.id, cwd, project, mtimeMs: f.mtimeMs, preview: meta.preview }; + }); +} diff --git a/src/server.ts b/src/server.ts index 9d32ea4..ba8fad0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -37,6 +37,7 @@ import { loadConfig } from './config.js' import { parseClientMessage, serialize } from './protocol.js' import { isOriginAllowed } from './http/origin.js' import { parseHookEvent } from './http/hook.js' +import { listSessions } from './http/history.js' import { createSessionManager } from './session/manager.js' import { detachWs, writeInput, resize } from './session/session.js' import type { Config } from './types.js' @@ -105,6 +106,11 @@ export function startServer(cfg: Config): { close(): Promise } { const publicDir = path.join(__dirname, '..', 'public') app.use(express.static(publicDir)) + // ── Claude Code history (O2) β€” list past sessions for the resume browser ── + app.get('/sessions', (_req, res) => { + res.json(listSessions(50)) + }) + // ── Claude Code hook side-channel (H2) ──────────────────────────────────── // Hooks running inside a spawned shell POST status here (loopback only β€” the // shell runs on the host). sessionId arrives in the X-Webterm-Session header. diff --git a/test/history.test.ts b/test/history.test.ts new file mode 100644 index 0000000..7136f07 --- /dev/null +++ b/test/history.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { parseSessionMeta } from '../src/http/history.js' + +describe('parseSessionMeta', () => { + it('extracts cwd and the first user prompt (string content)', () => { + const jsonl = [ + JSON.stringify({ type: 'last-prompt', sessionId: 'x' }), + JSON.stringify({ type: 'attachment', cwd: '/Users/me/proj', sessionId: 'x' }), + JSON.stringify({ type: 'user', message: { role: 'user', content: 'Add monitoring please' } }), + ].join('\n') + expect(parseSessionMeta(jsonl)).toEqual({ cwd: '/Users/me/proj', preview: 'Add monitoring please' }) + }) + + it('reads the first text block when content is an array', () => { + const jsonl = [ + JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: [{ type: 'text', text: 'hello there' }] } }), + ].join('\n') + expect(parseSessionMeta(jsonl)).toEqual({ cwd: '/p', preview: 'hello there' }) + }) + + it('collapses whitespace and truncates long previews to 120 chars', () => { + const long = 'a'.repeat(200) + const jsonl = JSON.stringify({ type: 'user', cwd: '/p', message: { role: 'user', content: `line1\n line2 ${long}` } }) + const { preview } = parseSessionMeta(jsonl) + expect(preview.length).toBe(120) + expect(preview.startsWith('line1 line2 ')).toBe(true) + }) + + it('returns nulls/empty when there is no cwd or user message', () => { + expect(parseSessionMeta('')).toEqual({ cwd: null, preview: '' }) + expect(parseSessionMeta('not json\n{"type":"summary"}')).toEqual({ cwd: null, preview: '' }) + }) +})