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. +
ev.stopPropagation()}> + setDraft(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') void commitRename(s.id); + if (ev.key === 'Escape') setRenaming(null); + }} + className="min-w-0 flex-1 rounded border border-duck-dark/20 bg-transparent px-1.5 py-0.5 text-xs" + /> + + +
+ ) : ( + 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; +}