feat(v0.3): O2 — resume past Claude sessions
- 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 <id>'
- 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.
This commit is contained in:
109
public/history.ts
Normal file
109
public/history.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { mountSearch } from './search.js'
|
|||||||
import { mountQrConnect } from './qr.js'
|
import { mountQrConnect } from './qr.js'
|
||||||
import { mountSettings, loadSettings, type Settings } from './settings.js'
|
import { mountSettings, loadSettings, type Settings } from './settings.js'
|
||||||
import { mountDashboard } from './dashboard.js'
|
import { mountDashboard } from './dashboard.js'
|
||||||
|
import { mountHistory } from './history.js'
|
||||||
|
|
||||||
const paneHost = document.getElementById('term')
|
const paneHost = document.getElementById('term')
|
||||||
const tabs = document.getElementById('tabs')
|
const tabs = document.getElementById('tabs')
|
||||||
@@ -51,6 +52,9 @@ mountDashboard(toolbar, {
|
|||||||
snapshot: () => app.snapshot(),
|
snapshot: () => app.snapshot(),
|
||||||
focus: (idx) => app.focusTab(idx),
|
focus: (idx) => app.focusTab(idx),
|
||||||
})
|
})
|
||||||
|
mountHistory(toolbar, {
|
||||||
|
resume: (cwd, id) => app.newTabForResume(cwd, id),
|
||||||
|
})
|
||||||
mountQrConnect(toolbar)
|
mountQrConnect(toolbar)
|
||||||
|
|
||||||
// PWA: register the service worker (installable + offline shell, M4).
|
// PWA: register the service worker (installable + offline shell, M4).
|
||||||
|
|||||||
@@ -253,6 +253,73 @@ html, body {
|
|||||||
font-weight: 600;
|
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 {
|
.tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -151,7 +151,12 @@ export class TabApp {
|
|||||||
|
|
||||||
/* ── tab lifecycle ───────────────────────────────────────────── */
|
/* ── 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 = {
|
const entry: TabEntry = {
|
||||||
session: null as unknown as TerminalSession,
|
session: null as unknown as TerminalSession,
|
||||||
customTitle,
|
customTitle,
|
||||||
@@ -162,6 +167,7 @@ export class TabApp {
|
|||||||
entry.session = new TerminalSession({
|
entry.session = new TerminalSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
...(cwd !== undefined ? { cwd } : {}),
|
...(cwd !== undefined ? { cwd } : {}),
|
||||||
|
...(initialInput !== undefined ? { initialInput } : {}),
|
||||||
onSessionId: () => this.persist(),
|
onSessionId: () => this.persist(),
|
||||||
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
|
// onActivity only fires for hidden (inactive) panes (see TerminalSession).
|
||||||
onActivity: () => {
|
onActivity: () => {
|
||||||
@@ -198,6 +204,14 @@ export class TabApp {
|
|||||||
this.activate(this.tabs.length - 1)
|
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 {
|
activate(i: number): void {
|
||||||
if (i < 0 || i >= this.tabs.length) return
|
if (i < 0 || i >= this.tabs.length) return
|
||||||
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
this.maybeAskNotify() // first switch is a user gesture — request notif permission
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface TerminalSessionOpts {
|
|||||||
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
|
onClaudeStatus?: (status: ClaudeStatus, detail?: string) => void
|
||||||
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
|
/** Optional: spawn a NEW session in this directory ("new tab here", M6). */
|
||||||
cwd?: string
|
cwd?: string
|
||||||
|
/** Optional: type this once the new session's shell is ready (O2 resume). */
|
||||||
|
initialInput?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export class TerminalSession {
|
export class TerminalSession {
|
||||||
@@ -69,6 +71,8 @@ export class TerminalSession {
|
|||||||
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
private readonly onStatus: ((status: SessionStatus) => void) | undefined
|
||||||
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
private readonly onClaudeStatus: ((status: ClaudeStatus, detail?: string) => void) | undefined
|
||||||
private readonly spawnCwd: string | undefined
|
private readonly spawnCwd: string | undefined
|
||||||
|
private readonly initialInput: string | undefined
|
||||||
|
private initialSent = false
|
||||||
private statusValue: SessionStatus = 'connecting'
|
private statusValue: SessionStatus = 'connecting'
|
||||||
private claudeStatusValue: ClaudeStatus = 'unknown'
|
private claudeStatusValue: ClaudeStatus = 'unknown'
|
||||||
private cwdValue: string | null = null
|
private cwdValue: string | null = null
|
||||||
@@ -94,6 +98,7 @@ export class TerminalSession {
|
|||||||
this.onStatus = opts.onStatus
|
this.onStatus = opts.onStatus
|
||||||
this.onClaudeStatus = opts.onClaudeStatus
|
this.onClaudeStatus = opts.onClaudeStatus
|
||||||
this.spawnCwd = opts.cwd
|
this.spawnCwd = opts.cwd
|
||||||
|
this.initialInput = opts.initialInput
|
||||||
|
|
||||||
this.el = document.createElement('div')
|
this.el = document.createElement('div')
|
||||||
this.el.className = 'term-pane'
|
this.el.className = 'term-pane'
|
||||||
@@ -238,6 +243,12 @@ export class TerminalSession {
|
|||||||
this.onSessionId(msg.sessionId)
|
this.onSessionId(msg.sessionId)
|
||||||
const dims = this.safefit()
|
const dims = this.safefit()
|
||||||
if (dims !== null) this.sendResize(dims.cols, dims.rows)
|
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
|
break
|
||||||
}
|
}
|
||||||
case 'output': {
|
case 'output': {
|
||||||
|
|||||||
121
src/http/history.ts
Normal file
121
src/http/history.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import { loadConfig } from './config.js'
|
|||||||
import { parseClientMessage, serialize } from './protocol.js'
|
import { parseClientMessage, serialize } from './protocol.js'
|
||||||
import { isOriginAllowed } from './http/origin.js'
|
import { isOriginAllowed } from './http/origin.js'
|
||||||
import { parseHookEvent } from './http/hook.js'
|
import { parseHookEvent } from './http/hook.js'
|
||||||
|
import { listSessions } from './http/history.js'
|
||||||
import { createSessionManager } from './session/manager.js'
|
import { createSessionManager } from './session/manager.js'
|
||||||
import { detachWs, writeInput, resize } from './session/session.js'
|
import { detachWs, writeInput, resize } from './session/session.js'
|
||||||
import type { Config } from './types.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')
|
const publicDir = path.join(__dirname, '..', 'public')
|
||||||
app.use(express.static(publicDir))
|
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) ────────────────────────────────────
|
// ── Claude Code hook side-channel (H2) ────────────────────────────────────
|
||||||
// Hooks running inside a spawned shell POST status here (loopback only — the
|
// 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.
|
// shell runs on the host). sessionId arrives in the X-Webterm-Session header.
|
||||||
|
|||||||
33
test/history.test.ts
Normal file
33
test/history.test.ts
Normal 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: '' })
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user