link cleared sessions back to the conversation they continue
/clear starts a new claude session and the list showed it as an unrelated conversation. claude records no parent link anywhere — not in compactMetadata, logicalParentUuid, summary.leafUuid, the per-session slug, or the live process registry — so infer it: a cleared transcript opens with /clear, and /clear happens inside one process, so the parent is the conversation in the same group that was writing to disk at the instant this one began (4ms apart, measured). matching is on per-minute activity rather than updatedAt, because resuming a parent moves its end time past its child's birth and lost the link entirely for two of the three cleared sessions here. the window is symmetric because clearing makes claude summarise the conversation it is ending, so the parent's final record can land after the child's first. ambiguity fails closed — the wrong parent also renames the conversation. read-only: nothing is written back to claude's store, and an explicit title always wins. also: cleared sessions were titled "<command-name>/clear</command-name>" because claude does not set isMeta on slash commands; and the chat header was hardcoded to undefined, so it read "New chat" above every conversation you opened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { Link, 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;
|
||||
@@ -22,6 +23,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"). */
|
||||
title?: string | null;
|
||||
/** The conversation this one continues, when the server could identify it. */
|
||||
continuedFrom?: { id: string; title: string } | null;
|
||||
} | null;
|
||||
|
||||
const CHANNEL = 'chat:selected-session';
|
||||
@@ -37,16 +42,28 @@ 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;
|
||||
isConnected: boolean;
|
||||
isGenerating: boolean;
|
||||
onDisconnect?: () => void;
|
||||
};
|
||||
|
||||
function DetailBar({ sessionTitle, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
|
||||
function DetailBar({ sessionTitle, continuedFrom, 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 text-center text-sm font-medium text-foreground/80 truncate px-3">
|
||||
{sessionTitle ?? 'New chat'}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{!isConnected ? (
|
||||
@@ -79,9 +96,12 @@ type NewChatProps = {
|
||||
total?: number;
|
||||
initialOffset?: number;
|
||||
sessionCwd?: string | null;
|
||||
sessionTitle?: string | null;
|
||||
continuedFrom?: { id: string; title: string } | null;
|
||||
};
|
||||
|
||||
function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd }: NewChatProps) {
|
||||
function NewChat(props: NewChatProps) {
|
||||
const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd } = props;
|
||||
const location = useLocation();
|
||||
const locationState = location.state as ChatLocationState;
|
||||
const { invalidate: invalidateClaudeSessions } = useClaudeSessions();
|
||||
@@ -125,7 +145,11 @@ function NewChat({ resumeSummary, resumeSessionId, initialMessages, total, initi
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<DetailBar
|
||||
sessionTitle={undefined}
|
||||
// Was hardcoded `undefined`, so every resumed conversation was headed "New chat". The title now
|
||||
// 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}
|
||||
isConnected={chat.isConnected}
|
||||
isGenerating={chat.isGenerating}
|
||||
onDisconnect={chat.disconnectSession}
|
||||
@@ -163,6 +187,8 @@ export const ChatDetailPanel = () => {
|
||||
total={selected.total}
|
||||
initialOffset={selected.initialOffset}
|
||||
sessionCwd={selected.cwd}
|
||||
sessionTitle={selected.title}
|
||||
continuedFrom={selected.continuedFrom}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X } from 'lucide-react';
|
||||
import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, CornerDownRight } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, RelativeTime } from '@/components/Data';
|
||||
import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||
@@ -201,6 +201,14 @@ 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>
|
||||
</MetaItem>
|
||||
),
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { useAuth } from 'hooks/useAuth';
|
||||
|
||||
const SESSIONS_KEY = 'CLAUDE_SESSIONS';
|
||||
|
||||
/** The conversation a session continues. Server-inferred — see `applyLineage` in claude-sessions.ts. */
|
||||
export type ClaudeSessionOrigin = { id: string; title: string };
|
||||
|
||||
export type ClaudeSessionSummary = {
|
||||
id: string; // Claude session uuid (= transcript filename)
|
||||
title: string;
|
||||
@@ -13,6 +16,14 @@ export type ClaudeSessionSummary = {
|
||||
updatedAt: string;
|
||||
messageCount: number;
|
||||
harness?: 'claude' | 'opencode'; // which harness produced it (absent = claude)
|
||||
/** 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.
|
||||
*/
|
||||
continuedFrom?: ClaudeSessionOrigin | null;
|
||||
};
|
||||
|
||||
// Display-ready message, matching the frontend ChatMessage union (rebuilt from Claude's transcript).
|
||||
@@ -35,6 +46,10 @@ export type ClaudeSessionDetail = {
|
||||
messages: ClaudeSessionMessage[];
|
||||
total: number; // full transcript length (the loaded messages may be a tail window)
|
||||
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;
|
||||
};
|
||||
|
||||
export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; isDefault: boolean };
|
||||
|
||||
Reference in New Issue
Block a user