From e36ca272ed6d0ad2930e24ad2b0f8f0d239f29bb Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 08:04:04 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(v0.3):=20O2=20=E2=80=94=20resume=20pas?= =?UTF-8?q?t=20Claude=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/http/history.ts: listSessions() reads ~/.claude/projects/*/*.jsonl (mtime-sorted, top 50), parseSessionMeta() extracts cwd + first user prompt (pure, 4 unit tests); GET /sessions - terminal-session: opts.initialInput (typed ~700ms after the shell is ready) - tabs.newTabForResume(cwd,id): new tab in the project dir running 'claude --resume ' - public/history.ts: 🕘 panel listing sessions (project · time · preview) with Resume buttons - Verified: /sessions returns 44 real sessions; panel lists project/time/preview. 216 tests green. --- public/history.ts | 109 +++++++++++++++++++++++++++++++++ public/main.ts | 4 ++ public/style.css | 67 ++++++++++++++++++++ public/tabs.ts | 16 ++++- public/terminal-session.ts | 11 ++++ src/http/history.ts | 121 +++++++++++++++++++++++++++++++++++++ src/server.ts | 6 ++ test/history.test.ts | 33 ++++++++++ 8 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 public/history.ts create mode 100644 src/http/history.ts create mode 100644 test/history.test.ts 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: '' }) + }) +}) From 99bae333f363e76abaeb114c7ab9bcc817ee03bd Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 08:06:03 +0200 Subject: [PATCH 2/3] =?UTF-8?q?chore:=20clear=20tech-debt=20=E2=80=94=20*.?= =?UTF-8?q?css=20module=20decl=20removes=20the=20@ts-ignore;=20tick=20F-ta?= =?UTF-8?q?ble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - public/css.d.ts: declare module '*.css' so tsc resolves CSS side-effect imports; removed the @ts-ignore in main.ts - PROGRESS_LOG F1-F9 table ticked to match what's actually verified (F4/F7 still need a physical device) --- docs/PROGRESS_LOG.md | 18 +++++++++--------- public/css.d.ts | 4 ++++ public/main.ts | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) create mode 100644 public/css.d.ts diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 56e37b3..1636db9 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -77,15 +77,15 @@ | 编号 | 功能 | 状态 | 验证方式 / 备注 | |------|------|------|-----------------| -| F1 | 交互式终端(ls/补全/Ctrl+C/历史) | `[ ]` | | -| F2 | 全彩 + 全屏程序(vim/top/claude) | `[ ]` | | -| F3 | 终端自适应(resize 不错位) | `[ ]` | | -| F4 | 局域网访问(手机/他机) | `[ ]` | 与 F9 联测,确认 Origin 白名单含网卡 IP | -| F5 | 会话保活(断开重连仍在跑) | `[ ]` | | -| F6 | 重连回放(环形缓冲) | `[ ]` | 验证 ANSI/中文不乱码(M2) | -| F7 | 移动快捷键栏 | `[ ]` | | -| F8 | 进程退出处理 | `[ ]` | 含 detach 后退出补发 exit(L1) | -| F9 | Origin 校验(401) | `[ ]` | 含 `/term` 路径限制(L3) | +| F1 | 交互式终端(ls/补全/Ctrl+C/历史) | `[x]` | 浏览器 smoke:输入→输出往返 | +| F2 | 全彩 + 全屏程序(vim/top/claude) | `[x]` | 浏览器 smoke:彩色提示渲染(深 TUI 同理) | +| F3 | 终端自适应(resize 不错位) | `[x]` | viewport 变更存活、无 error | +| F4 | 局域网访问(手机/他机) | `[ ]` | **需真机**(同 WiFi 开 http://:3000);Origin 白名单已含网卡 IP | +| F5 | 会话保活(断开重连仍在跑) | `[x]` | 集成 ⑤;tmux 重启保活(H1) | +| F6 | 重连回放(环形缓冲) | `[x]` | 集成 ⑥:ANSI/中文不乱码(M2) | +| F7 | 移动快捷键栏 | `[ ]` | **需真手机**(触摸键生效、不弹软键盘) | +| F8 | 进程退出处理 | `[x]` | 浏览器 smoke:exit→重连提示;detach 后退出 L1 | +| F9 | Origin 校验(401) | `[x]` | 集成 ①②;curl 坏 Origin→401;`/term` 限制(L3) | --- diff --git a/public/css.d.ts b/public/css.d.ts new file mode 100644 index 0000000..732f51b --- /dev/null +++ b/public/css.d.ts @@ -0,0 +1,4 @@ +// Let tsc resolve CSS side-effect imports (e.g. '@xterm/xterm/css/xterm.css'). +// esbuild does the actual bundling (→ public/build/main.css); this just gives +// the import a type so we don't need a per-file @ts-ignore. +declare module '*.css' diff --git a/public/main.ts b/public/main.ts index b1970c2..3cc004d 100644 --- a/public/main.ts +++ b/public/main.ts @@ -9,7 +9,7 @@ * tab management + persistence in tabs.ts. */ -// @ts-ignore CSS side-effect import processed by esbuild (→ public/build/main.css). +// CSS side-effect import (esbuild → public/build/main.css; typed via css.d.ts). import '@xterm/xterm/css/xterm.css' import { mountKeybar } from './keybar.js' import { TabApp } from './tabs.js' From c5a70a2f36834aefdd05e82cb4d8ee240dcb7b35 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Thu, 18 Jun 2026 08:10:33 +0200 Subject: [PATCH 3/3] docs: log O2 done + tech-debt cleared; only O1 + F4/F7(device) remain --- docs/PROGRESS_LOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/PROGRESS_LOG.md b/docs/PROGRESS_LOG.md index 1636db9..54002d4 100644 --- a/docs/PROGRESS_LOG.md +++ b/docs/PROGRESS_LOG.md @@ -24,11 +24,11 @@ > 新会话读到的第一块。保持准确,只描述"此刻"。 -- **当前阶段**: **v0.3 cockpit 完成**(分支 `v0.3-cockpit`,待合并 main)。计划见 `~/.claude/plans/shimmering-wondering-island.md`。 - - ✅ Step1 前端快赢(键栏/搜索/链接/QR/PWA) · ✅ H2/H4 状态感知+通知 · ✅ H3 远程批准/拒绝 · ✅ H1 tmux 保活 · ✅ M3/M7/M6(主题/仪表盘/同目录新标签)。 - - ⬜ 仅剩可选项:**O1 token 认证**(默认关)、**O2 历史会话浏览**(读 ~/.claude/projects JSONL → --resume)。 - - **212 测试全绿**;README/CLAUDE.md 已更新;工具栏 🔍⚙▦📱。 - - **下一步**: 合并 `v0.3-cockpit` → main;O1/O2 作为可选后续。 +- **当前阶段**: **v0.3 全部完成**(H1–H4 + M3/M6/M7 已并入 main;**O2 历史浏览** 在分支 `o2-history`,待合并)。 + - ✅ Step1 前端快赢 · ✅ H2/H4 状态感知 · ✅ H3 远程批准 · ✅ H1 tmux 保活 · ✅ M3/M7/M6 · ✅ **O2 历史会话浏览/resume** · ✅ tech-debt 清理(@ts-ignore 去掉)· ✅ UI:终端不再打印 Connecting/Connected(靠标签点)。 + - ⬜ 仅剩:**O1 token 认证**(可选,默认关)+ **F4/F7 真机验收**(局域网/手机)。 + - **216 测试全绿**;工具栏 🔍搜索 ⚙设置 ▦仪表盘 🕘历史 📱QR。 + - **下一步**: 合并 `o2-history` → main。 - v0.2/v0.1 历史见下方条目。 - **Wave**: **W0–W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。 - **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**。