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
@@ -3,7 +3,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash } from 'lucide-react';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash, Eraser } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
@@ -150,6 +150,21 @@ export const MessageBubble = ({ message, onAnswer }: MessageBubbleProps) => {
case 'system':
return <CollapsibleBlock label="System prompt" content={message.text} />;
// The one seam in a merged `/clear` chain. It says what was lost rather than just drawing a line,
// because the risk of merging is precisely that the conversation looks unbroken to you and is not:
// ask about something above this and the agent has never seen it.
case 'divider':
return (
<div className="flex items-center gap-3 py-3 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<Eraser className="h-3 w-3" />
Context cleared nothing above this is in memory
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
case 'assistant': {
const assistantText = typeof message.text === 'string' ? message.text : '';
if (!assistantText) return null;
@@ -14,6 +14,7 @@ function firstMessageKey(m: ChatMessage | undefined): string {
if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`;
if (m.role === 'tool') return `t:${m.toolCallId}`;
if (m.role === 'task') return `k:${m.taskId}`;
if (m.role === 'divider') return `d:${m.sessionId}`;
return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`;
}
@@ -38,6 +38,11 @@ export type ChatMessage =
}
| { role: 'result'; cost: MessageCost }
| { role: 'error'; text: string }
// Where a `/clear` fell inside a resumed conversation. The server splices the parts of a chain into
// one transcript (see `loadChainTranscript`), and this is the seam — deliberately visible, because
// your history runs straight through it and the agent's context does not. `sessionId` is the part
// that ends here.
| { role: 'divider'; sessionId: string }
| {
// A background task (run_in_background / Monitor). One row for its whole life: it appears pending
// and resolves in place, rather than as two unrelated bubbles minutes apart.
@@ -1,12 +1,11 @@
import { useCallback } from 'react';
import { Link, useLocation } from 'react-router';
import { useLocation } from 'react-router';
import { Unplug } from 'lucide-react';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useAuth } from 'hooks/useAuth';
import { useClaudeSessions } from 'state/useClaudeSessions';
import { useChat, EmbeddableChat } from '../Chat';
import type { ChatMessage } from '../Chat/types';
import { chatSessionPath } from './chat-routes';
export type SelectedSession = {
id: string;
@@ -23,10 +22,10 @@ export type SelectedSession = {
* the default dir instead of the project. For a new chat it is the group the list is showing.
*/
cwd?: string | null;
/** What the list calls this conversation. Includes the `/clear`-chain numbering ("Platform Arch 2"). */
/** What the list calls this conversation — the whole `/clear` chain's title when it is one. */
title?: string | null;
/** The conversation this one continues, when the server could identify it. */
continuedFrom?: { id: string; title: string } | null;
/** Transcripts spliced into `initialMessages`; `> 1` when this is a merged `/clear` chain. */
partCount?: number;
} | null;
const CHANNEL = 'chat:selected-session';
@@ -42,27 +41,22 @@ type ChatLocationState = {
type DetailBarProps = {
sessionTitle: string | undefined;
/** What this conversation continues, if anything. Rendered as a link back to it. */
continuedFrom?: { id: string; title: string } | null;
/** Transcripts behind this conversation; `> 1` when `/clear` parts have been merged into it. */
partCount?: number;
isConnected: boolean;
isGenerating: boolean;
onDisconnect?: () => void;
};
function DetailBar({ sessionTitle, continuedFrom, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
function DetailBar({ sessionTitle, partCount, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
return (
<div className="shrink-0 flex items-center px-4 py-2 border-b border-border bg-background/60">
<div className="flex-1 min-w-0 px-3 text-center">
<div className="truncate text-sm font-medium text-foreground/80">{sessionTitle ?? 'New chat'}</div>
{/* Here it IS a link, unlike in the list row: nothing wraps this, so there is no anchor to nest
inside. Following it is the whole point — `/clear` is where the context you want went. */}
{continuedFrom && (
<Link
to={chatSessionPath(continuedFrom.id)}
className="block truncate text-xs text-muted-foreground hover:text-foreground hover:underline"
>
continues {continuedFrom.title}
</Link>
{/* This replaced a link back to the previous session. There is nowhere to go now — the previous
session is scrolled up above you, with a divider where the clear happened. */}
{partCount != null && partCount > 1 && (
<div className="truncate text-xs text-muted-foreground">continued across {partCount} sessions</div>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -97,7 +91,7 @@ type NewChatProps = {
initialOffset?: number;
sessionCwd?: string | null;
sessionTitle?: string | null;
continuedFrom?: { id: string; title: string } | null;
partCount?: number;
};
function NewChat(props: NewChatProps) {
@@ -149,7 +143,7 @@ function NewChat(props: NewChatProps) {
// comes from the same place the list gets it, which is also what makes the `/clear` numbering
// agree in both views instead of only in the row.
sessionTitle={props.sessionTitle ?? undefined}
continuedFrom={props.continuedFrom}
partCount={props.partCount}
isConnected={chat.isConnected}
isGenerating={chat.isGenerating}
onDisconnect={chat.disconnectSession}
@@ -188,7 +182,7 @@ export const ChatDetailPanel = () => {
initialOffset={selected.initialOffset}
sessionCwd={selected.cwd}
sessionTitle={selected.title}
continuedFrom={selected.continuedFrom}
partCount={selected.partCount}
/>
);
};
@@ -1,6 +1,6 @@
import { useRef, useState, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, CornerDownRight } from 'lucide-react';
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data';
import { usePanelChannel } from 'hooks/usePanelChannel';
@@ -201,20 +201,26 @@ export const SessionList = () => {
) : (
`${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`
),
// What `/clear` cost you, put back. Plain text, not a link: the row already IS a
// link and nesting one inside it is the thing this list was fixed to stop doing.
// Follow the chain by opening the parent from its own row — it is right below.
session.continuedFrom && (
<MetaItem key="c" icon={CornerDownRight}>
<span className="max-w-[14rem] truncate">continues {session.continuedFrom.title}</span>
// One row, several transcripts: this conversation was `/clear`ed and carried on,
// and the server has spliced the parts back together. Worth saying out loud —
// the message count above covers all of them, and opening the row shows all of
// them, so the row is not standing for the single file its id names.
session.partCount && session.partCount > 1 ? (
<MetaItem key="c" icon={Layers}>
{session.partCount} parts
</MetaItem>
),
) : null,
]}
/>
{isConfirming ? (
<div className="mr-2 flex shrink-0 items-center gap-1">
<span className="text-xs text-destructive">Delete?</span>
{/* Say the size of it. Deleting a merged chain removes every part, and a row
that looks like one conversation giving no warning that it is three files is
exactly the kind of surprise this confirm exists to prevent. */}
<span className="text-xs text-destructive">
{session.partCount && session.partCount > 1 ? `Delete all ${session.partCount}?` : 'Delete?'}
</span>
<button
onClick={() => handleDelete(session.id)}
className="cursor-pointer rounded p-1 text-destructive hover:bg-destructive/10"
+13 -6
View File
@@ -19,11 +19,16 @@ export type ClaudeSessionSummary = {
/** This conversation was opened by `/clear`ing another one. */
bornFromClear?: boolean;
/**
* The conversation this one continues, when the server could identify it. Absent means either not a
* continuation or a continuation whose parent could not be named with confidence — the two are
* deliberately indistinguishable here, because a guess is worth nothing to the reader.
* The conversation this one continues, when the server could identify it. Always absent on a listed
* row — the list merges a chain into one row, so there is no earlier row left to point at. It still
* carries the inference that made the merge possible.
*/
continuedFrom?: ClaudeSessionOrigin | null;
/**
* Transcripts behind this row. `> 1` means a `/clear` chain the server has merged into one
* conversation; absent or 1 is the ordinary case.
*/
partCount?: number;
};
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
@@ -37,7 +42,9 @@ export type ClaudeSessionMessage =
toolCallId: string;
output?: string;
isError?: boolean;
};
}
/** The seam between two parts of a merged `/clear` chain. `sessionId` is the part that ends there. */
| { role: 'divider'; sessionId: string };
export type ClaudeSessionDetail = {
id: string;
@@ -48,8 +55,8 @@ export type ClaudeSessionDetail = {
offset: number; // absolute index of messages[0]; older messages remain above when > 0
/** The title the list shows this session under, so the open conversation is labelled the same way. */
title?: string | null;
/** The conversation this one continues, if any — so an open chat can link back to what preceded it. */
continuedFrom?: ClaudeSessionOrigin | null;
/** Transcripts spliced into `messages` — `> 1` when this is a merged `/clear` chain. */
partCount?: number;
};
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };