merge a /clear chain into one conversation

The list collapses each chain to its newest link — the only one that can be
resumed — carrying the root's title and start time, the summed message count
and a part badge. The detail splices the chain's transcripts oldest-first with
a divider between parts, server-side, so the client's index-window pagination
needed no change.

The divider says "context cleared — nothing above this is in memory", because
the whole risk of merging is that the history reads as continuous when the
agent's context is not. Delete cascades the chain and the confirm says how many.

Supersedes the "continues X" line from the previous commit: there is nowhere to
link to once the parent is scrolled up above you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 00:44:33 +00:00
co-authored by Claude Opus 5
parent 3e01de100e
commit 47894702ac
10 changed files with 296 additions and 67 deletions
+8 -6
View File
@@ -74,10 +74,10 @@ chatRouter.get('/sessions/:id', async (ctx) => {
const windowed = limit != null || beforeRaw != null;
const messages = windowed ? detail.messages.slice(start, end) : detail.messages;
// The title the list shows this session under, and what it continues. Resolved against `detail.cwd`
// — the transcript's own directory — not the requested `cwd`, which on a deep link is still the
// default group and holds none of this session's neighbours. OpenCode has no lineage of its own, so
// it gets neither rather than a fabricated answer.
// The title the list shows this session under, and how many `/clear` parts it spans. Resolved against
// `detail.cwd` — the transcript's own directory — not the requested `cwd`, which on a deep link is
// still the default group and holds none of this session's neighbours. OpenCode has no chains of its
// own, so it gets neither rather than a fabricated answer.
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id);
return ctx.json({
@@ -86,11 +86,13 @@ chatRouter.get('/sessions/:id', async (ctx) => {
total,
offset: windowed ? start : 0,
title: context?.title ?? null,
continuedFrom: context?.continuedFrom ?? null,
partCount: context?.partCount ?? 1,
});
});
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store.
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store. For a
// Claude `/clear` chain that is every part of it: the list shows the chain as one conversation, so
// deleting it deletes one conversation.
chatRouter.delete('/sessions/:id', async (ctx) => {
const email = ctx.get('user').email;
const id = ctx.req.param('id');
+171 -24
View File
@@ -11,7 +11,7 @@ import {
closeSync,
realpathSync,
} from 'node:fs';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
// ── Claude session store (source of truth) ──
@@ -54,6 +54,11 @@ export type ClaudeSessionSummary = {
bornFromClear?: boolean;
/** The conversation this one continues, when it could be identified. Inferred — see `applyLineage`. */
continuedFrom?: ClaudeSessionOrigin | null;
/**
* How many transcripts this row stands for. Present only on a `/clear` chain, which the list shows as
* one conversation — see `mergeChains`. Absent means the ordinary case: one row, one transcript.
*/
partCount?: number;
};
/** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */
@@ -241,15 +246,14 @@ function nextInChain(parentTitle: string): string {
*
* (`/compact` needs none of this. It appends to the same transcript and keeps the session id.)
*/
function applyLineage(summaries: TranscriptSummary[]): ClaudeSessionSummary[] {
function applyLineage(summaries: TranscriptSummary[]): TranscriptSummary[] {
// Oldest first, so a parent's own computed title is settled before its child asks for it and a chain
// of clears numbers straight through.
const ordered = [...summaries].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
const resolved = new Map<string, ClaudeSessionSummary>();
const resolved = new Map<string, TranscriptSummary>();
for (const session of ordered) {
const { hasExplicitTitle, activeMinutes: _activeMinutes, ...rest } = session;
const out: ClaudeSessionSummary = { ...rest };
const out: TranscriptSummary = { ...session };
const startedAt = Date.parse(session.createdAt);
if (session.bornFromClear && Number.isFinite(startedAt)) {
@@ -260,7 +264,7 @@ function applyLineage(summaries: TranscriptSummary[]): ClaudeSessionSummary[] {
const parent = only ? (resolved.get(only.id) ?? only) : null;
if (parent) {
out.continuedFrom = { id: parent.id, title: parent.title };
if (!hasExplicitTitle) out.title = nextInChain(parent.title);
if (!session.hasExplicitTitle) out.title = nextInChain(parent.title);
}
}
resolved.set(session.id, out);
@@ -269,6 +273,59 @@ function applyLineage(summaries: TranscriptSummary[]): ClaudeSessionSummary[] {
return summaries.map((session) => resolved.get(session.id) ?? session);
}
/** Drop the fields the app has no business seeing — `activeMinutes` would go over the wire as `{}`. */
function publish(session: TranscriptSummary): ClaudeSessionSummary {
const { hasExplicitTitle: _hasExplicitTitle, activeMinutes: _activeMinutes, ...rest } = session;
return rest;
}
/** A session and every conversation it continues, oldest first. Cycles cannot happen, but guard anyway. */
function chainOf(head: TranscriptSummary, byId: Map<string, TranscriptSummary>): TranscriptSummary[] {
const parts: TranscriptSummary[] = [];
const seen = new Set<string>();
let cursor: TranscriptSummary | undefined = head;
while (cursor && !seen.has(cursor.id)) {
seen.add(cursor.id);
parts.unshift(cursor);
cursor = cursor.continuedFrom ? byId.get(cursor.continuedFrom.id) : undefined;
}
return parts;
}
/**
* Show a `/clear` chain as the one conversation it is: a single row, standing for every part.
*
* The row is the **newest** link, not the oldest, because that is the only one you can carry on — a
* cleared session is finished, and `--resume` on it would start a second branch. Everything else on the
* row is the whole chain's: the root's start time, the summed message count, and the root's title, so a
* conversation does not rename itself every time you clear it. A rename still wins, and it is the head
* that carries it, which is the same id the row links to and acts on.
*
* `continuedFrom` is cleared here on purpose. It described a hop the reader now never has to make —
* `loadChainTranscript` has already spliced the parts into one transcript — and leaving it would put a
* "continues Platform Arch" line above a conversation that visibly contains Platform Arch.
*/
function mergeChains(sessions: TranscriptSummary[]): TranscriptSummary[] {
const byId = new Map(sessions.map((session) => [session.id, session]));
const continued = new Set(sessions.flatMap((session) => (session.continuedFrom ? [session.continuedFrom.id] : [])));
return sessions
.filter((session) => !continued.has(session.id))
.map((head) => {
const parts = chainOf(head, byId);
if (parts.length < 2) return head;
const root = parts[0]!;
return {
...head,
title: head.hasExplicitTitle ? head.title : root.title,
createdAt: root.createdAt,
messageCount: parts.reduce((total, part) => total + part.messageCount, 0),
partCount: parts.length,
continuedFrom: null,
};
});
}
// ── Loading a full transcript for display ──
// App-facing message shape (matches the frontend ChatMessage union), rebuilt from Claude's blocks.
export type ClaudeChatMessage =
@@ -281,7 +338,13 @@ export type ClaudeChatMessage =
toolCallId: string;
output?: string;
isError?: boolean;
};
}
// Where one part of a `/clear` chain ends and the next begins. Only ever produced by
// `loadChainTranscript`; `sessionId` is the part that ENDS here, which gives the row an identity the
// virtualised list can key on. It has to be visible, not smoothed over: the parts read as one
// conversation to you, but the agent's context was emptied at this line and nothing above it is in
// its memory.
| { role: 'divider'; sessionId: string };
type ContentBlock =
| { type: 'text'; text?: string }
@@ -374,9 +437,48 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
return { id: sessionId, model, cwd: sessionCwd, messages };
}
/**
* Splice in everything this conversation continues, so a `/clear` chain reads as one transcript.
*
* `/clear` is a fact about Claude's process, not about what you were doing — you were having one
* conversation, and it left you with three unrelated-looking rows. The parts are concatenated
* oldest-first with a `divider` between them, and the divider is the honest part: your history is
* continuous, the agent's context is not.
*
* Only ancestors, never descendants. Ancestry is what "continues" means, and it keeps the returned id
* resumable — a mid-chain deep link from an old bookmark shows its own story up to that point rather
* than a conversation that carries on past where it was addressed.
*
* Pagination needs nothing: the client asks for index windows into whatever the server calls the
* transcript, so a longer one simply pages further back.
*/
function loadChainTranscript(email: string, detail: ClaudeSessionDetail): ClaudeSessionDetail {
const parts = (() => {
const group = scanGroup(email, detail.cwd);
const head = group.find((session) => session.id === detail.id);
return head ? chainOf(head, new Map(group.map((session) => [session.id, session]))) : [];
})();
if (parts.length < 2) return detail;
const dir = join(claudeProjectsDir(email), projectSlug(detail.cwd));
const earlier: ClaudeChatMessage[] = [];
for (const part of parts.slice(0, -1)) {
const segment = parseClaudeTranscript(join(dir, `${part.id}.jsonl`), part.id, detail.cwd);
if (!segment) continue;
earlier.push(...segment.messages, { role: 'divider', sessionId: part.id });
}
return { ...detail, messages: [...earlier, ...detail.messages] };
}
/** Load a session when its cwd (project group) is known. */
export function loadClaudeSession(email: string, cwd: string, sessionId: string): ClaudeSessionDetail | null {
return parseClaudeTranscript(join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`), sessionId, cwd);
const detail = parseClaudeTranscript(
join(claudeProjectsDir(email), projectSlug(cwd), `${sessionId}.jsonl`),
sessionId,
cwd,
);
return detail && loadChainTranscript(email, detail);
}
/** Resolve a session by id ALONE — scan every project group for its transcript. Used on a deep-link /
@@ -392,7 +494,9 @@ export function loadClaudeSessionById(email: string, sessionId: string): ClaudeS
}
for (const slug of slugs) {
const filePath = join(projectsDir, slug, `${sessionId}.jsonl`);
if (existsSync(filePath)) return parseClaudeTranscript(filePath, sessionId);
if (!existsSync(filePath)) continue;
const detail = parseClaudeTranscript(filePath, sessionId);
return detail && loadChainTranscript(email, detail);
}
return null;
}
@@ -422,11 +526,25 @@ function findTranscript(email: string, cwd: string, sessionId: string): string |
return null;
}
/** Delete a session by removing its transcript file. Returns false if it didn't exist. */
/**
* Delete a conversation by removing its transcript file — and, when it is a `/clear` chain, the files
* of every part it continues. Returns false if it didn't exist.
*
* The chain is resolved from the transcript's OWN directory, not the caller's `cwd`: the two disagree
* routinely (see `findTranscript`), and the wrong group would find no chain and silently delete one
* part of several.
*/
export function deleteClaudeSession(email: string, cwd: string, sessionId: string): boolean {
const filePath = findTranscript(email, cwd, sessionId);
if (!filePath) return false;
rmSync(filePath);
const ownCwd = firstCwd(filePath);
const ids = ownCwd ? chainFileIds(email, ownCwd, sessionId) : [sessionId];
const dir = dirname(filePath);
for (const id of ids) {
const partPath = join(dir, `${id}.jsonl`);
if (existsSync(partPath)) rmSync(partPath);
}
return true;
}
@@ -620,8 +738,14 @@ export function listClaudePwds(email: string): ClaudePwd[] {
.sort((a, b) => (a.isDefault ? -1 : b.isDefault ? 1 : b.updatedAt.localeCompare(a.updatedAt)));
}
/** List sessions Claude has stored for a given working directory, newest first. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
/**
* Every transcript in a working directory, one entry each, with lineage resolved.
*
* Lineage is worked out across the whole group at once — a session's parent is only knowable relative
* to its neighbours, so there is no per-file answer to cache. The per-file summaries underneath it are
* mtime-cached, which is what makes calling this on every request cheap.
*/
function scanGroup(email: string, cwd: string): TranscriptSummary[] {
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
if (!existsSync(dir)) return [];
@@ -631,24 +755,47 @@ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSum
const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, ''));
if (summary) sessions.push(summary);
}
// Lineage is resolved across the whole group at once — a session's parent is only knowable relative
// to its neighbours, so there is no per-file answer to cache.
return applyLineage(sessions).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
return applyLineage(sessions);
}
/** Conversations Claude has stored for a working directory, newest first, one row per `/clear` chain. */
export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSummary[] {
return mergeChains(scanGroup(email, cwd))
.map(publish)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
/**
* One session's list-level facts: the title it is shown under, and what it continues.
* One session's list-level facts: the title it is shown under, and how many parts it spans.
*
* A transcript cannot answer either on its own. The title may be a number in a `/clear` chain and the
* parent is by definition a fact about the session's neighbours, so this goes through the group listing
* — which is cheap, because the per-file summaries behind it are mtime-cached. It exists so the open
* conversation is labelled the same way its row is, instead of the two drifting.
* A transcript cannot answer either on its own — both are properties of the `/clear` chain it belongs
* to — so this goes through the group listing. It exists so the open conversation is labelled the same
* way its row is, instead of the two drifting.
*
* The fallback matters: the merged list only holds chain heads, so a link to a part in the middle of a
* chain (an old bookmark, or the parent link from before merging) finds nothing there. Such a link
* still opens a real transcript, so answer for the part itself rather than 404 the title.
*/
export function claudeSessionContext(
email: string,
cwd: string,
sessionId: string,
): { title: string; continuedFrom: ClaudeSessionOrigin | null } | null {
const session = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId);
return session ? { title: session.title, continuedFrom: session.continuedFrom ?? null } : null;
): { title: string; partCount: number } | null {
const merged = listClaudeSessions(email, cwd).find((entry) => entry.id === sessionId);
if (merged) return { title: merged.title, partCount: merged.partCount ?? 1 };
const part = scanGroup(email, cwd).find((entry) => entry.id === sessionId);
return part ? { title: part.title, partCount: 1 } : null;
}
/**
* Every transcript that has to go when this conversation is deleted: itself and everything it
* continues. The row stands for the whole chain, so deleting it has to mean the whole chain — leaving
* the ancestors behind would resurrect them as separate rows the moment their child was gone, which
* reads as the delete having half worked.
*/
function chainFileIds(email: string, cwd: string, sessionId: string): string[] {
const group = scanGroup(email, cwd);
const head = group.find((session) => session.id === sessionId);
if (!head) return [sessionId];
return chainOf(head, new Map(group.map((session) => [session.id, session]))).map((part) => part.id);
}