diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx index 31959c3e..24d3c58e 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx @@ -1,4 +1,6 @@ -import { ArrowLeft } from 'lucide-react'; +import { useEffect, useRef } from 'react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; +import { serverClient } from 'hooks/useServerClient'; import { connectionLabel } from 'hooks/connections'; import { SessionList } from './SessionList'; import { ChatDetailPanel } from './ChatDetailPanel'; @@ -27,6 +29,60 @@ type ChatPaneProps = { export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => { const open = !!target; + /** + * Load the transcript for a row this pane just opened. + * + * The screen-level resolver does this for the single-panel layout, but it writes to the shared + * channel — which a pane deliberately does not read. So a pane clicked a row, got `{id, title, cwd}` + * and nothing else, and rendered an empty conversation while the list behind it reset to the default + * directory. It has to resolve its own, from ITS server: two machines can hold the same uuid, so + * asking the wrong one is not merely empty, it is wrong. + */ + const resolvingRef = useRef(null); + const id = target?.id ?? null; + const needsTranscript = !!id && !id.startsWith('new:') && !target?.resumeSessionId; + const serverId = target?.serverId ?? null; + + useEffect(() => { + if (!needsTranscript || !id) return; + if (resolvingRef.current === id) return; // one fetch per row, not one per render + resolvingRef.current = id; + let cancelled = false; + (async () => { + try { + const detail = await serverClient(serverId).get<{ + model?: string | null; + messages: unknown[]; + total: number; + offset: number; + cwd: string; + title?: string | null; + partCount?: number; + }>(`/chat/sessions/${id}?limit=20`); + if (cancelled) return; + onTargetChange({ + id, + serverId, + model: detail.model, + resumeSessionId: id, + initialMessages: detail.messages as never, + total: detail.total, + initialOffset: detail.offset, + cwd: detail.cwd, + title: detail.title ?? undefined, + partCount: detail.partCount, + }); + } catch { + // Leave the pane on the row it has. Falling back to an empty chat would look like a conversation + // that lost its history rather than one that could not be read. + if (!cancelled) resolvingRef.current = null; + } + })(); + return () => { + cancelled = true; + }; + }, [needsTranscript, id, serverId, onTargetChange]); + return (
@@ -50,7 +106,17 @@ export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPanePr
)} -
{open ? : }
+
+ {!open ? ( + + ) : needsTranscript ? ( +
+ +
+ ) : ( + + )} +
); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx index bebbd2e5..739aae42 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx @@ -21,6 +21,16 @@ type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null const PaneSelectionContext = createContext(null); +/** + * Is this component inside a pane? + * + * A pane owns its conversation AND its directory, so it must not take either from the address bar: + * three panes cannot share one URL. Outside a pane the route stays the authority, exactly as before. + */ +export function useIsInPane(): boolean { + return useContext(PaneSelectionContext) !== null; +} + export function usePaneSelection(): PaneSelectionValue { const scoped = useContext(PaneSelectionContext); const channel = useSelectedChatSession(); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 99629874..aedcf245 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'; 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 { usePaneSelection } from './PaneSelection'; +import { usePaneSelection, useIsInPane } from './PaneSelection'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { ServerChips } from './ServerChips'; @@ -24,7 +24,11 @@ export const SessionList = () => { // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. - const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; + // In a pane the directory is the pane's, not the route's — three panes cannot share one URL, and + // letting the address bar win is what reset this list to the default the moment a row was clicked. + const inPane = useIsInPane(); + const [paneCwd, setPaneCwd] = useState(null); + const activeCwd = inPane ? paneCwd : (cwdFromSplat(splat) ?? selected?.cwd ?? null); // Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another // machine is the entire point, and a shared "current server" would make that impossible to express. @@ -88,10 +92,11 @@ export const SessionList = () => { value={serverId} onChange={(next) => { setServerId(next); + setPaneCwd(null); // A path and a conversation from the machine you left name nothing on the one you arrived // at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`). setSelected(null); - navigate(chatListPath(null), { replace: true }); + if (!inPane) navigate(chatListPath(null), { replace: true }); }} /> { value={activeCwd} onChange={(cwd) => { setSelected(null); // sessions belong to a cwd — clear the open one when switching - navigate(chatListPath(cwd), { replace: true }); + if (inPane) setPaneCwd(cwd); + else navigate(chatListPath(cwd), { replace: true }); }} />
@@ -206,7 +212,7 @@ export const SessionList = () => { {/* The row is the link and the actions are its siblings — a