diff --git a/docs/chat-ui-walkthrough.md b/docs/chat-ui-walkthrough.md
index 71369a50..240799f6 100644
--- a/docs/chat-ui-walkthrough.md
+++ b/docs/chat-ui-walkthrough.md
@@ -275,6 +275,76 @@ step 4 above is the one I most want you to actually try, because it is the bug t
---
+## 14. `/clear` no longer loses the thread
+
+**What to click.** Open `/chat` on this project's group. Look at the top of the list. This conversation
+should be titled **“Platform Arch 2”**, with a second meta line reading _continues Platform Arch_. Open
+it: the header — which used to say “New chat” for every conversation you opened — now says “Platform
+Arch 2”, and under it is a link back to the original. Click it, and you are in the conversation this one
+grew out of.
+
+Nothing was migrated to make that happen. It is derived from transcripts that were already on disk, so
+it applied to your history the moment the server restarted.
+
+**What was actually wrong.** You guessed right: `/clear` starts a genuinely new Claude session, and the
+list was showing it as an unrelated conversation. Two separate defects were stacked on top of that.
+
+The first was cosmetic but ugly: cleared sessions were titled `/clear`.
+Claude records slash commands as ordinary user entries and — this is the part that fooled the original
+code — **does not set `isMeta` on them**, so the “first human message” that becomes the title was the
+`/clear` itself. Skipping `` entries fixes it, and is why these sessions now fall back to
+their first real message when no parent can be found.
+
+The second is the interesting one. **Claude records no parent link anywhere.** I checked all five places
+it could plausibly live: `compactMetadata` and `logicalParentUuid` (those are in-file compaction — see
+below), `summary.leafUuid` (every one resolves inside its own transcript, never across files), the
+per-session `slug` (a random name like `precious-brewing-kazoo`, not a lineage), and
+`~/.claude/sessions/.json` (a live process registry, gone when the process is). So the link has to
+be inferred, and this is the only inference in that file.
+
+**What it infers from.** A cleared transcript opens with `/clear`, and `/clear` happens inside one
+process — the old transcript's last write and the new one's first write are the same moment. Measured
+here: 4ms apart. So the parent is the conversation in the same group that was writing to disk at the
+instant this one began.
+
+Two things about that rule are not obvious, and both were found by running it against your real history
+rather than by reasoning:
+
+- **The window is symmetric.** Clearing makes Claude summarise the conversation it is ending, and that
+ costs a model call — so the parent's _final_ record can land a few seconds **after** the child's first
+ one. One session here sits exactly there, and a before-only window silently lost it.
+- **It matches on activity, never on “when did it end”.** My first version compared the child's birth to
+ the parent's last-modified time. That threw away any parent you later went back and **resumed**, because
+ resuming moves its end time days past its child's birth. Two of your three cleared sessions were in
+ that state and found no parent at all. Each transcript now carries the set of minutes it wrote in, which
+ survives resumption.
+
+**Where it says nothing.** If two transcripts in the group were active in that minute, it names neither —
+no parent is a far smaller mistake than the wrong parent, because the wrong parent also **renames** the
+conversation. One session of yours (in `~/dockers`) still shows no parent: its nearest candidate was
+active three hours away, which means the conversation it came from is genuinely not on disk any more.
+That is the rule working, not failing.
+
+**It is read-only, on purpose.** Nothing is written back into Claude's store, so the numbering is a
+display-time guess that costs nothing if it is wrong and disappears on the next read. An explicit title —
+Claude's own summary, or a rename you typed — always wins and is never overwritten. My suggestion is to
+live with it for a week; if it never guesses wrong, we can promote it to a real `summary` record so the
+name sticks in the terminal too. That is deliberately not built yet.
+
+**`/compact` needed nothing.** I had assumed it forked a session like `/clear` does. It does not — it
+appends to the same transcript and keeps the same session id (63 in-file compactions in the parent of
+this conversation alone). So compaction was already invisible in the list, correctly.
+
+**Also fixed in passing:** the chat header was hardcoded to `sessionTitle={undefined}`, so it read “New
+chat” above every conversation you opened, resumed or not. It now takes the title from the same place
+the list row does, which is also what keeps the two from disagreeing about the numbering.
+
+**Not verified:** the inference itself I ran against your real transcripts and checked case by case (the
+numbers above are measurements, not estimates). The UI — the meta line, the header, the link back — has
+not been through a browser.
+
+---
+
## Things noticed and deliberately left alone
- **`useChatWebSocket` silently ignores unparseable frames.** That one is intentional and the comment
diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
index eac936fa..44a726fd 100644
--- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx
@@ -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;
diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts
index 1968b4c8..348f39e9 100644
--- a/src/servers/api/chat/chat.ts
+++ b/src/servers/api/chat/chat.ts
@@ -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.
diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts
index 68b4396e..81b74d4e 100644
--- a/src/servers/api/chat/claude-sessions.ts
+++ b/src/servers/api/chat/claude-sessions.ts
@@ -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;
};
/** 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();
+
+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();
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
+ // `…`, 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 "/clear");
+ // - 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('/clear');
+ }
+ if (text && !firstUserText && !text.startsWith('')) 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();
+
+ 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;
}
diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx
index 9bf7bbf7..4e46d793 100644
--- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx
+++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx
@@ -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 (
-
- {sessionTitle ?? 'New chat'}
+
+
{sessionTitle ?? 'New chat'}
+ {/* 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 && (
+
+ continues {continuedFrom.title}
+
+ )}
{
total={selected.total}
initialOffset={selected.initialOffset}
sessionCwd={selected.cwd}
+ sessionTitle={selected.title}
+ continuedFrom={selected.continuedFrom}
/>
);
};
diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx
index be0164ed..9161c790 100644
--- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx
+++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx
@@ -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 && (
+
+ continues {session.continuedFrom.title}
+
+ ),
]}
/>
diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts
index 63fdd0e7..73735d56 100644
--- a/src/workspaces/state/src/useClaudeSessions.ts
+++ b/src/workspaces/state/src/useClaudeSessions.ts
@@ -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 };