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:
@@ -94,6 +94,8 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
||||
total: detail.total,
|
||||
initialOffset: detail.offset,
|
||||
cwd: detail.cwd,
|
||||
title: detail.title,
|
||||
continuedFrom: detail.continuedFrom,
|
||||
});
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
deleteClaudeSession,
|
||||
renameClaudeSession,
|
||||
loadBackgroundTask,
|
||||
claudeSessionContext,
|
||||
} from './claude-sessions';
|
||||
import {
|
||||
listOpenCodeSessions,
|
||||
@@ -73,7 +74,20 @@ chatRouter.get('/sessions/:id', async (ctx) => {
|
||||
const windowed = limit != null || beforeRaw != null;
|
||||
const messages = windowed ? detail.messages.slice(start, end) : detail.messages;
|
||||
|
||||
return ctx.json({ ...detail, messages, total, offset: windowed ? start : 0 });
|
||||
// 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.
|
||||
const context = isOpenCodeSessionId(id) ? null : claudeSessionContext(email, detail.cwd, id);
|
||||
|
||||
return ctx.json({
|
||||
...detail,
|
||||
messages,
|
||||
total,
|
||||
offset: windowed ? start : 0,
|
||||
title: context?.title ?? null,
|
||||
continuedFrom: context?.continuedFrom ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// DELETE /chat/sessions/:id[?cwd=] — remove a conversation from the owning harness's store.
|
||||
|
||||
@@ -39,14 +39,37 @@ const claudeProjectsDir = (email: string): string => join(claudeHome(email), '.c
|
||||
/** Claude's folder name for a working directory. */
|
||||
export const projectSlug = (cwd: string): string => cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
|
||||
/** What a session's continuation looks like to the app. See `applyLineage` for how it is derived. */
|
||||
export type ClaudeSessionOrigin = { id: string; title: string };
|
||||
|
||||
export type ClaudeSessionSummary = {
|
||||
id: string; // session uuid (= transcript filename)
|
||||
title: string; // first human message, trimmed
|
||||
title: string; // explicit title, else first human message, else a number in a /clear chain
|
||||
cwd: string;
|
||||
createdAt: string; // ISO, first entry
|
||||
updatedAt: string; // ISO, last entry
|
||||
messageCount: number; // human + assistant turns
|
||||
harness?: 'claude' | 'opencode'; // which harness produced it (defaults to claude when absent)
|
||||
/** This transcript opens with `/clear`, so it is the continuation of an earlier conversation. */
|
||||
bornFromClear?: boolean;
|
||||
/** The conversation this one continues, when it could be identified. Inferred — see `applyLineage`. */
|
||||
continuedFrom?: ClaudeSessionOrigin | null;
|
||||
};
|
||||
|
||||
/** `ClaudeSessionSummary` plus the bits only lineage resolution needs. Never leaves this module. */
|
||||
type TranscriptSummary = ClaudeSessionSummary & {
|
||||
/** The title came from a `summary` record — Claude's own, or an Officer rename. Never overwrite it. */
|
||||
hasExplicitTitle: boolean;
|
||||
/**
|
||||
* Every minute in which this transcript recorded anything (epoch minutes).
|
||||
*
|
||||
* Lineage needs "was this conversation alive at the instant that one started", and `updatedAt` cannot
|
||||
* answer it: a session that is cleared and then **resumed later** has an `updatedAt` long after its
|
||||
* child was born. Two of the three cleared sessions on this machine were in exactly that state, so
|
||||
* matching on `updatedAt` found no parent for either. Minute buckets survive resumption and cost one
|
||||
* integer per minute of activity rather than one per entry.
|
||||
*/
|
||||
activeMinutes: Set<number>;
|
||||
};
|
||||
|
||||
/** Pull the plain text out of a transcript entry's `message.content` (string or content-block array). */
|
||||
@@ -78,7 +101,26 @@ type Entry = {
|
||||
isMeta?: boolean;
|
||||
};
|
||||
|
||||
function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary | null {
|
||||
/**
|
||||
* Summaries keyed by path, invalidated on mtime. A group's transcripts are re-read on every list AND
|
||||
* on every session open, and they run to tens of megabytes here — the same file was being parsed
|
||||
* several times a second. An mtime key is exact for this store: Claude only ever appends.
|
||||
*/
|
||||
const summaryCache = new Map<string, { mtimeMs: number; summary: TranscriptSummary }>();
|
||||
|
||||
function summarizeTranscript(filePath: string, id: string): TranscriptSummary | null {
|
||||
let mtimeMs: number;
|
||||
let mtime: string;
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
mtimeMs = stat.mtimeMs;
|
||||
mtime = stat.mtime.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const cached = summaryCache.get(filePath);
|
||||
if (cached && cached.mtimeMs === mtimeMs) return cached.summary;
|
||||
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf-8');
|
||||
@@ -92,6 +134,9 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
||||
let firstTs = '';
|
||||
let lastTs = '';
|
||||
let messageCount = 0;
|
||||
let sawFirstUserEntry = false;
|
||||
let bornFromClear = false;
|
||||
const activeMinutes = new Set<number>();
|
||||
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
@@ -105,26 +150,123 @@ function summarizeTranscript(filePath: string, id: string): ClaudeSessionSummary
|
||||
if (entry.timestamp) {
|
||||
if (!firstTs) firstTs = entry.timestamp;
|
||||
lastTs = entry.timestamp;
|
||||
const at = Date.parse(entry.timestamp);
|
||||
if (Number.isFinite(at)) activeMinutes.add(Math.floor(at / 60_000));
|
||||
}
|
||||
if (entry.type === 'summary' && typeof entry.summary === 'string') {
|
||||
summaryTitle = entry.summary; // last one wins
|
||||
} else if (entry.type === 'user' || entry.type === 'assistant') {
|
||||
messageCount += 1;
|
||||
if (!firstUserText && entry.type === 'user' && !entry.isMeta) {
|
||||
firstUserText = entryText(entry.message).split('\n')[0]!.trim();
|
||||
if (entry.type === 'user' && !entry.isMeta) {
|
||||
const text = entryText(entry.message).split('\n')[0]!.trim();
|
||||
// Claude records `/clear`, `/compact` and friends as ordinary user entries wrapping
|
||||
// `<command-name>…`, and does NOT set `isMeta` on them. Two consequences:
|
||||
// - a slash command is not a title, so skip past them for `firstUserText` (every cleared
|
||||
// session used to be listed as "<command-name>/clear</command-name>");
|
||||
// - a transcript whose very first user entry is `/clear` was opened by clearing another
|
||||
// conversation, which is the only trace of that handoff Claude leaves anywhere.
|
||||
if (text && !sawFirstUserEntry) {
|
||||
sawFirstUserEntry = true;
|
||||
bornFromClear = text.startsWith('<command-name>/clear</command-name>');
|
||||
}
|
||||
if (text && !firstUserText && !text.startsWith('<command-name>')) firstUserText = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mtime = statSync(filePath).mtime.toISOString();
|
||||
return {
|
||||
const summary: TranscriptSummary = {
|
||||
id,
|
||||
title: summaryTitle || firstUserText || '(untitled)',
|
||||
hasExplicitTitle: Boolean(summaryTitle),
|
||||
bornFromClear,
|
||||
cwd,
|
||||
createdAt: firstTs || mtime,
|
||||
updatedAt: lastTs || mtime,
|
||||
messageCount,
|
||||
activeMinutes,
|
||||
};
|
||||
summaryCache.set(filePath, { mtimeMs, summary });
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this conversation writing to disk around the given instant?
|
||||
*
|
||||
* `/clear` happens inside one process, so the old transcript's last write and the new transcript's
|
||||
* first are the same moment — 4ms apart in the case measured here (…28.525Z → …28.529Z).
|
||||
*
|
||||
* The window is **symmetric**, which is not obvious and was found the hard way. Clearing makes Claude
|
||||
* summarise the conversation it is ending, and that costs a model call: the parent's final `summary`
|
||||
* record can therefore land *after* the child's first entry. One session on this machine sits exactly
|
||||
* there — its parent's nearest write is a minute the wrong side of the birth — and a
|
||||
* before-only window silently lost it.
|
||||
*
|
||||
* It asks about *activity*, never about `updatedAt`. A conversation that is cleared and then **resumed
|
||||
* later** has an `updatedAt` days after its child was born, so an end-time test discards that parent
|
||||
* outright.
|
||||
*/
|
||||
const wasActiveAt = (session: TranscriptSummary, at: number): boolean => {
|
||||
const minute = Math.floor(at / 60_000);
|
||||
return (
|
||||
session.activeMinutes.has(minute - 1) || session.activeMinutes.has(minute) || session.activeMinutes.has(minute + 1)
|
||||
);
|
||||
};
|
||||
|
||||
/** "Platform Arch" → "Platform Arch 2" → "Platform Arch 3". */
|
||||
function nextInChain(parentTitle: string): string {
|
||||
// A title already ending in a number continues its count. This does mean a conversation genuinely
|
||||
// called "Sidecar stage 3" would be continued as "Sidecar stage 4" — accepted, because it is a
|
||||
// display-only guess and renaming overrides it.
|
||||
const numbered = parentTitle.match(/^(.*\S)\s+(\d+)$/);
|
||||
if (numbered) return `${numbered[1]} ${Number(numbered[2]) + 1}`;
|
||||
return `${parentTitle} 2`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link each `/clear`-born session to the conversation it continues, and give it a numbered title.
|
||||
*
|
||||
* **This is an inference, and the only one in this file.** Claude records no parent: not in
|
||||
* `compactMetadata`/`logicalParentUuid` (those are in-file compaction), not in `summary.leafUuid`
|
||||
* (every one resolves inside its own transcript), not in `slug` (random per session), not in
|
||||
* `~/.claude/sessions/*.json` (a live process registry). What it does record is that the new transcript
|
||||
* opens with `/clear` and that the old one was still writing that same second — so the parent is the
|
||||
* conversation in the same group that was alive at the instant this one began.
|
||||
*
|
||||
* **Ambiguity fails closed.** If two transcripts in the group were active in that minute — two claude
|
||||
* processes in one directory, which does happen here — neither is named. No parent is a much smaller
|
||||
* mistake than the wrong parent, because the wrong parent also renames the conversation.
|
||||
*
|
||||
* Read-only: nothing is written back to Claude's store, so a wrong guess is cosmetic and disappears on
|
||||
* the next read. An explicit title always wins — this never overwrites a rename.
|
||||
*
|
||||
* (`/compact` needs none of this. It appends to the same transcript and keeps the session id.)
|
||||
*/
|
||||
function applyLineage(summaries: TranscriptSummary[]): ClaudeSessionSummary[] {
|
||||
// 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>();
|
||||
|
||||
for (const session of ordered) {
|
||||
const { hasExplicitTitle, activeMinutes: _activeMinutes, ...rest } = session;
|
||||
const out: ClaudeSessionSummary = { ...rest };
|
||||
const startedAt = Date.parse(session.createdAt);
|
||||
|
||||
if (session.bornFromClear && Number.isFinite(startedAt)) {
|
||||
const candidates = ordered.filter((other) => other.id !== session.id && wasActiveAt(other, startedAt));
|
||||
const only = candidates.length === 1 ? candidates[0]! : null;
|
||||
// A parent resolved earlier in this loop may itself have been renumbered, so prefer that copy —
|
||||
// that is what makes "Arch" → "Arch 2" → "Arch 3" work instead of two conversations called "Arch 2".
|
||||
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);
|
||||
}
|
||||
}
|
||||
resolved.set(session.id, out);
|
||||
}
|
||||
|
||||
return summaries.map((session) => resolved.get(session.id) ?? session);
|
||||
}
|
||||
|
||||
// ── Loading a full transcript for display ──
|
||||
@@ -483,11 +625,30 @@ export function listClaudeSessions(email: string, cwd: string): ClaudeSessionSum
|
||||
const dir = join(claudeProjectsDir(email), projectSlug(cwd));
|
||||
if (!existsSync(dir)) return [];
|
||||
|
||||
const sessions: ClaudeSessionSummary[] = [];
|
||||
const sessions: TranscriptSummary[] = [];
|
||||
for (const file of readdirSync(dir)) {
|
||||
if (!file.endsWith('.jsonl')) continue;
|
||||
const summary = summarizeTranscript(join(dir, file), file.replace(/\.jsonl$/, ''));
|
||||
if (summary) sessions.push(summary);
|
||||
}
|
||||
return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
// 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));
|
||||
}
|
||||
|
||||
/**
|
||||
* One session's list-level facts: the title it is shown under, and what it continues.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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