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} + + )}
{!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 (
{ 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 };