diff --git a/src/servers/api/dashboards/dashboards.ts b/src/servers/api/dashboards/dashboards.ts
index ae563abf..3ccf6e15 100644
--- a/src/servers/api/dashboards/dashboards.ts
+++ b/src/servers/api/dashboards/dashboards.ts
@@ -20,7 +20,7 @@ export const dashboardsRouter = createRouter();
// `editor` joined 2026-08-15: which files a code-editor panel has open, and which is in front. Note an
// unmatched key is a 400 rather than a silent 200 — so a new panel prefix MUST be added here or its state
// vanishes loudly instead of quietly, which is the whole reason that branch exists.
-const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code', 'editor', 'files'];
+const PANEL_STATE_PREFIXES = ['tmux', 'nvim', 'claude-code', 'editor', 'files', 'chat'];
// A `LayoutNode` is an object. Everything from the PATCH body down to jsonb is `unknown`, so without this
// the only thing standing between a wrong-shaped body and the layout columns is the CHECK constraint —
diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx
index 7ab62bdb..edd812fd 100644
--- a/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx
+++ b/src/workspaces/officerdev/src/apps/Chat/ChatHeader.tsx
@@ -1,4 +1,15 @@
-import { MessageSquare } from 'lucide-react';
+import { useState } from 'react';
+import { MessageSquare, ChevronDown, Plus, Pencil, Trash2, Check, X } from 'lucide-react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { useClaudeSessions } from 'state/useClaudeSessions';
+import { useWorkspace, cwdToPath } from '../../components/Workspace';
+import { usePanelSession } from './use-panel-session';
import type { ChatMessage } from './types';
export type ChatSessionSelection = {
@@ -9,11 +20,123 @@ export type ChatSessionSelection = {
initialMessages?: ChatMessage[];
};
-// Chat panel header — just the label now that saved-sessions are retired (Claude's transcript store
-// is the source of truth; the /chat screen is where you browse/resume sessions).
-export const ChatHeader = () => (
- <>
-
- Chat
- >
-);
+// The chat panel's header: which conversation this panel is on, and a way to change it.
+//
+// A dashboard is rooted in a directory, and Claude stores every transcript under that directory's slug
+// (`~/.claude/projects//`). So "the conversations belonging to this dashboard" is not something
+// Officer has to track — it is how they already sit on disk, and it is the same list the terminal `claude`
+// sees. `GET /chat/sessions?cwd=` returns them merged across both harnesses.
+//
+// Which one THIS panel is on lives in dashboard state (see `usePanelSession`), so two chat panels side by
+// side hold different conversations and both survive a reload.
+
+const title = (s: { title: string }) => s.title || '(untitled)';
+
+export const ChatHeader = ({ panelId }: { panelId?: string }) => {
+ const { workspace, cwd } = useWorkspace();
+ const cwdPath = cwdToPath(cwd);
+ // The endpoint takes a home-relative path and treats absent as "the caller's default", which is what an
+ // unscoped panel wants.
+ const { sessions, renameSession, deleteSession } = useClaudeSessions(cwdPath === '/' ? null : cwdPath);
+ const [sessionId, setSessionId] = usePanelSession(panelId, workspace);
+
+ const [renaming, setRenaming] = useState(null);
+ const [draft, setDraft] = useState('');
+
+ const current = sessions.find((s) => s.id === sessionId);
+ const label = current ? title(current) : sessionId ? 'Conversation' : 'New chat';
+
+ const commitRename = async (id: string) => {
+ const next = draft.trim();
+ setRenaming(null);
+ if (next) await renameSession(id, next);
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ {/* `null` rather than a minted id: the body mounts a fresh chat and records whatever the harness
+ names it, because only the harness can decide that. */}
+ setSessionId(null)} className="cursor-pointer">
+
+ New chat
+
+
+ {sessions.length > 0 && }
+
+ {sessions.map((s) =>
+ renaming === s.id ? (
+ // Inline, because the alternative is a modal over a panel header — and the only thing being
+ // asked for is one line of text.
+
+ ) : (
+ setSessionId(s.id)}
+ className={`cursor-pointer ${s.id === sessionId ? 'bg-duck-teal/10' : ''}`}
+ >
+ {title(s)}
+ {/* Siblings of the label, never nested inside it — these mutate, they do not select. */}
+
+
+
+
+
+ ),
+ )}
+
+
+ {cwd}
+ >
+ );
+};
diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx
index a734befb..222c5924 100644
--- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx
+++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx
@@ -5,6 +5,7 @@ import { useChat } from '../../hooks/useChat';
import { EmbeddableChat } from './EmbeddableChat';
import { AgentIdentityBar } from './AgentIdentityBar';
import { useAgentPanel, type UseAgentPanelType } from './useAgentPanel';
+import { usePanelSession } from './use-panel-session';
type ChatPanelInnerProps = {
scoped: boolean;
@@ -15,6 +16,8 @@ type ChatPanelInnerProps = {
onTurnComplete?: (hadToolCalls: boolean) => void;
sessionKey?: string;
agentPanel: UseAgentPanelType;
+ /** Records the id the harness mints for a brand-new conversation, so a reload comes back to it. */
+ onSessionId?: (id: string | null) => void;
};
const ChatPanelInner = ({
@@ -26,6 +29,7 @@ const ChatPanelInner = ({
onTurnComplete,
sessionKey,
agentPanel,
+ onSessionId,
}: ChatPanelInnerProps) => {
const chat = useChat(sessionKey, undefined, {
replaceUrl: false,
@@ -36,6 +40,9 @@ const ChatPanelInner = ({
useEffect(() => {
setActiveSession(chat.sessionId);
+ // A new chat starts with no id and is named by the harness on the first turn. Recording it here is
+ // what makes "New chat, say something, reload" come back to that conversation instead of a third one.
+ onSessionId?.(chat.sessionId);
}, [chat.sessionId]);
return (
@@ -94,12 +101,18 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
// same home-relative string resolved by the same function.
const cwdParam = agentPanel.agent?.cwd ? { path: agentPanel.agent.cwd } : scoped ? { root, path: cwd } : undefined;
- // A named panel is one session forever, so its key has to be decided before `useChat` mounts — the
- // hook seeds its state from the first `initialSessionId` it sees and the socket's resume handshake
- // sends whatever is in that state on connect. Remounting on the key is the honest way to say "this is
- // now a different conversation": it happens exactly twice in a panel's life, when it is named and if
- // it is released, and both are the user asking for precisely that.
- const key = agentPanel.agent?.sessionKey ?? 'anonymous';
+ // Which conversation this panel is showing, chosen in the header and shared live through dashboard
+ // state. A named agent panel still wins: it is one session forever by definition, and the header's
+ // picker is for the ordinary case where a dashboard simply holds several conversations about the
+ // directory it is rooted in.
+ const [panelSessionId, setPanelSessionId] = usePanelSession(panelId, workspace);
+ const selectedSessionId = agentPanel.agent?.sessionKey ?? panelSessionId ?? undefined;
+
+ // The key has to be decided before `useChat` mounts — the hook seeds its state from the first
+ // `initialSessionId` it sees, and the socket's resume handshake sends whatever is in that state on
+ // connect. Remounting on the key is the honest way to say "this is now a different conversation", and
+ // switching in the header is exactly that request.
+ const key = selectedSessionId ?? 'new';
// Until the address book has answered, a named panel must not mount an anonymous chat — that would
// start a throwaway session and stream the answer into a transcript nobody can find again.
@@ -114,7 +127,8 @@ export const ChatPanelWrapper = ({ panelId, promptPrefix }: ChatPanelWrapperProp
chatContext={chatContext}
setActiveSession={setActiveSession}
onTurnComplete={onTurnComplete}
- sessionKey={agentPanel.agent?.sessionKey}
+ sessionKey={selectedSessionId}
+ onSessionId={agentPanel.agent ? undefined : setPanelSessionId}
agentPanel={agentPanel}
/>
);
diff --git a/src/workspaces/officerdev/src/apps/Chat/use-panel-session.ts b/src/workspaces/officerdev/src/apps/Chat/use-panel-session.ts
new file mode 100644
index 00000000..183da270
--- /dev/null
+++ b/src/workspaces/officerdev/src/apps/Chat/use-panel-session.ts
@@ -0,0 +1,46 @@
+import { useCallback } from 'react';
+import { useDashboardState } from 'state/useDashboardState';
+import { panelStateKey, type WorkspaceIdentity } from '../../components/Workspace';
+
+/**
+ * Which conversation a chat panel is on, shared live between its header and its body.
+ *
+ * Deliberately NOT `usePanelMemory`, and the difference is the point. That hook claims its saved value
+ * ONCE and ignores the bag afterwards, because the editor and the file browser are the only writers of
+ * their own state and hearing it echo back would tell them to reopen a tab they had just closed.
+ *
+ * Here the header is the writer and the body is the reader. They are two components with no props
+ * between them, so the shared value has to stay LIVE — claim-once would mean picking a session in the
+ * dropdown changed the header's label and nothing else.
+ *
+ * Persistence comes free: it is dashboard state under `ws-chat-{dashboardId}`, keyed by panel, so it
+ * survives a reload for the same reason the editor's tabs do.
+ *
+ * `null` means "a new conversation" — the body mounts a fresh chat and records the id the harness mints
+ * as soon as it exists, so a reload after the first message comes back to the same transcript.
+ */
+export type ChatPanelSession = { sessionId: string | null };
+
+const EMPTY: Record = {};
+
+export function usePanelSession(panelId: string | undefined, workspace: WorkspaceIdentity | null) {
+ const { value: bag, setValue: setBag } = useDashboardState>(
+ panelStateKey('chat', workspace),
+ EMPTY,
+ );
+
+ const sessionId = panelId ? (bag[panelId]?.sessionId ?? null) : null;
+
+ const setSessionId = useCallback(
+ (next: string | null) => {
+ if (!panelId) return;
+ setBag((prev) => {
+ if ((prev[panelId]?.sessionId ?? null) === next) return prev;
+ return { ...prev, [panelId]: { sessionId: next } };
+ });
+ },
+ [panelId, setBag],
+ );
+
+ return [sessionId, setSessionId] as const;
+}