diff --git a/docs/chat-ui-walkthrough.md b/docs/chat-ui-walkthrough.md index 240799f6..5ffcf4cb 100644 --- a/docs/chat-ui-walkthrough.md +++ b/docs/chat-ui-walkthrough.md @@ -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 diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 4d583284..76d03218 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -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; diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 348f39e9..664f5be5 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -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'); diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 81b74d4e..a72ddd68 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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(); + const resolved = new Map(); 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): TranscriptSummary[] { + const parts: TranscriptSummary[] = []; + const seen = new Set(); + 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); } diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index ae680556..199a0ee5 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -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 ; + // 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 ( +
+
+ + + Context cleared — nothing above this is in memory + +
+
+ ); + case 'assistant': { const assistantText = typeof message.text === 'string' ? message.text : ''; if (!assistantText) return null; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index 224dd592..e1b9b2ea 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -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) : ''}`; } diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index 60d63d2f..a4f96f52 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -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. diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 4e46d793..1ca3d7b0 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -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 (
{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} - + {/* 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 && ( +
continued across {partCount} sessions
)}
@@ -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} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 9161c790..da1375e7 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -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 && ( - - continues {session.continuedFrom.title} + // 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 ? ( + + {session.partCount} parts - ), + ) : null, ]} /> {isConfirming ? (
- Delete? + {/* 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. */} + + {session.partCount && session.partCount > 1 ? `Delete all ${session.partCount}?` : 'Delete?'} +