merge: O2 resume-past-sessions + tech-debt cleanup + cleaner terminal

- O2: /sessions reads ~/.claude/projects JSONL; 🕘 panel resumes a past session
  in a new tab (claude --resume <id>) in its project dir
- chore: *.css module decl removes the last @ts-ignore
- ui: drop in-terminal Connecting/Connected lines (tab dot shows it)
216 tests green.
This commit is contained in:
Yaojia Wang
2026-06-18 08:10:33 +02:00
10 changed files with 385 additions and 16 deletions

View File

@@ -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 全部完成**(H1H4 + 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**: **W0W5 基本完成**。v0.1 功能完整、测试与真机浏览器验证通过。仅剩 2 项需用户真设备。
- **进度**: **20/21 任务**(T19 待真手机)· **187 自动化测试全绿** · 真 PTY 端到端验证 · **真浏览器 smoke 通过**
@@ -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://<IP>: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) |
---

4
public/css.d.ts vendored Normal file
View File

@@ -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'

109
public/history.ts Normal file
View File

@@ -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 <id>`.
*/
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<void> => {
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)
}

View File

@@ -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'
@@ -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).

View File

@@ -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;

View File

@@ -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 <id>`. */
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

View File

@@ -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': {

121
src/http/history.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* src/http/history.ts (O2) — list past Claude Code sessions for the resume browser.
*
* Reads ~/.claude/projects/<encoded>/<session-uuid>.jsonl. Each session's first
* user message is the preview and its `cwd` is the project directory (so resume
* can `claude --resume <id>` 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<string, unknown>;
try {
obj = JSON.parse(trimmed) as Record<string, unknown>;
} 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<string, unknown>)['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<string, unknown>;
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 };
});
}

View File

@@ -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<void> } {
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.

33
test/history.test.ts Normal file
View File

@@ -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: '' })
})
})