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:
@@ -345,6 +345,58 @@ not been through a browser.
|
||||
|
||||
---
|
||||
|
||||
## 15. A cleared conversation is one conversation again
|
||||
|
||||
**Where:** `/chat`. Look at the session list for `officer.dev`. It has one row fewer than it did
|
||||
yesterday, and “Platform Arch 2” is gone — there is a single **Platform Arch** carrying a `2 parts`
|
||||
badge. Open it and scroll up past the point where you cleared: the earlier conversation is above,
|
||||
with a line across the transcript reading **context cleared — nothing above this is in memory**.
|
||||
|
||||
**What changed.** Item 14 could tell that one conversation followed another, and said so in a meta
|
||||
line. This takes the obvious next step and stops showing them as two things at all. A `/clear` chain
|
||||
is now one row and one transcript.
|
||||
|
||||
**The list** collapses each chain to its **newest** link, not its oldest, because that is the only one
|
||||
you can carry on — a cleared session is finished, and `--resume` on it would fork a second branch. So
|
||||
the row's id, its link, its rename and its delete all address the head. Everything else on the row
|
||||
belongs to the whole chain: the root's start time, the summed message count, and the root's title. That
|
||||
last one is why the numbering disappeared from view — a conversation shouldn't rename itself every time
|
||||
you clear it, and `Platform Arch 2` was only ever a way of saying "this is still Platform Arch". Rename
|
||||
still wins over all of it, and it is the head that stores it. The numbered titles are still computed and
|
||||
still show up if you open a middle part directly from an old link.
|
||||
|
||||
**The transcript** is spliced server-side, in `loadChainTranscript`. This mattered for a boring reason:
|
||||
the chat client pages by index into whatever the server calls the transcript — `?before=` and `?limit=`
|
||||
plus a `total` — so a longer transcript simply pages further back and the client needed no changes at
|
||||
all. Only ancestors are spliced in, never descendants, which keeps the returned id resumable.
|
||||
|
||||
**The divider is the point, not a decoration.** The risk in merging is that the conversation now looks
|
||||
unbroken to you and is emphatically not: ask the agent about anything above that line and it has never
|
||||
seen it. So the seam says what was lost rather than just drawing a rule. It is a new `divider` variant
|
||||
on the `ChatMessage` union — the existing `system` role renders as a collapsible "System prompt" block
|
||||
and would have been the wrong thing entirely.
|
||||
|
||||
**Delete now takes the whole chain**, and the inline confirm says so — `Delete all 2?` rather than
|
||||
`Delete?`. Deleting only the head would have resurrected its parent as a separate row the moment its
|
||||
child was gone, which reads as the delete half working. The chain is resolved from the transcript's own
|
||||
directory rather than the requested one, because those two disagree routinely and the wrong group would
|
||||
find no chain and quietly delete one part of several.
|
||||
|
||||
**What this removed.** The "continues X" line in the row and the link back in the chat header, both
|
||||
built yesterday. There is nowhere to link to now — the previous conversation is scrolled up above you.
|
||||
The header shows `continued across 2 sessions` instead, which is a fact about the thing you are reading
|
||||
rather than a destination.
|
||||
|
||||
**Measured, not estimated:** `officer.dev` goes from 11 rows to 10, `dev-platform` from 2 to 1, each
|
||||
merged transcript carrying exactly one divider at the expected index. Still read-only — nothing is
|
||||
written back into Claude's store, so if the parent inference is ever wrong the damage is a list that
|
||||
looks odd until the next read, not a corrupted transcript.
|
||||
|
||||
**Not verified:** the browser. Everything above is measured against your real transcripts through the
|
||||
server code, not clicked through.
|
||||
|
||||
---
|
||||
|
||||
## Things noticed and deliberately left alone
|
||||
|
||||
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
|
||||
|
||||
@@ -111,7 +111,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
initialOffset: detail.offset,
|
||||
cwd: detail.cwd,
|
||||
title: detail.title,
|
||||
continuedFrom: detail.continuedFrom,
|
||||
partCount: detail.partCount,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user