extremely long transcripts made bottom-anchoring the virtualized list unreliable (thousands of unmeasured variable-height items = a huge estimate the scroll never lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed slice plus total+offset. the chat opens on the last 20 messages, anchors to the bottom instantly, and scrolling near the top pages in the next older window, prepending it and pinning the previously-top message so the view stays put. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
113 lines
4.3 KiB
TypeScript
113 lines
4.3 KiB
TypeScript
import { useEffect, useMemo, useRef } from 'react';
|
|
import { useParams, useNavigate } from 'react-router';
|
|
import type { LayoutNode, SelectedSession } from 'officerdev';
|
|
import { WorkspaceView } from 'officerdev';
|
|
import { useIsMobile } from 'hooks/useIsMobile';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { useDashboardState } from 'state/useDashboardState';
|
|
import type { ClaudeSessionDetail } from 'state/useClaudeSessions';
|
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
|
import { defaultLayout } from './defaultLayout';
|
|
|
|
// Allowed panel app types for the /chat screen
|
|
const ALLOWED_APP_TYPES = new Set<string | null>(['chat-session-list', 'chat-detail', null]);
|
|
|
|
// How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in.
|
|
const CHAT_TAIL = 20;
|
|
|
|
/** Recursively fix any panel that uses a wrong app type (e.g. officerdev/chat) */
|
|
function normalizeLayout(node: LayoutNode): LayoutNode {
|
|
if (node.type === 'panel') {
|
|
if (!ALLOWED_APP_TYPES.has(node.appType)) {
|
|
return { ...node, appType: 'chat-detail' };
|
|
}
|
|
return node;
|
|
}
|
|
const children = node.children.map((c) => {
|
|
const fixed = normalizeLayout(c.node);
|
|
return fixed === c.node ? c : { ...c, node: fixed };
|
|
});
|
|
const changed = children.some((c, i) => c !== node.children[i]);
|
|
return changed ? { ...node, children } : node;
|
|
}
|
|
|
|
type SessionListPageProps = {
|
|
isNew?: boolean;
|
|
};
|
|
|
|
export const SessionListPage = ({ isNew }: SessionListPageProps) => {
|
|
const { sessionId } = useParams<{ sessionId: string }>();
|
|
const [selected, setSelected] = usePanelChannel<SelectedSession>('chat:selected-session', null);
|
|
const [, setActiveCwd] = usePanelChannel<string | null>('chat:active-cwd', null);
|
|
const client = useClient();
|
|
const selectedRef = useRef(selected);
|
|
selectedRef.current = selected;
|
|
const rawWorkspace = useDashboardState<LayoutNode>('screens/chat', defaultLayout);
|
|
const isMobile = useIsMobile();
|
|
const navigate = useNavigate();
|
|
const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined;
|
|
|
|
// Normalize synchronously so the wrong panel never renders
|
|
const workspace = useMemo(() => {
|
|
const fixed = normalizeLayout(rawWorkspace.value);
|
|
if (fixed === rawWorkspace.value) return rawWorkspace;
|
|
return { ...rawWorkspace, value: fixed };
|
|
}, [rawWorkspace]);
|
|
|
|
// Persist the fix to the backend
|
|
useEffect(() => {
|
|
if (rawWorkspace.isLoaded && workspace.value !== rawWorkspace.value) {
|
|
rawWorkspace.setValue(workspace.value);
|
|
}
|
|
}, [rawWorkspace.isLoaded, workspace.value, rawWorkspace.value]);
|
|
|
|
// Fresh /chat/<id> (deep-link or refresh): all we have is the id. Resolve the session by id — the
|
|
// backend scans project groups — so the cwd picker lands on its real dir AND the chat resumes, exactly
|
|
// as clicking it from the list would. Guarded so it never clobbers an already-loaded selection.
|
|
// Transcripts get extremely long, so we load only the tail window (?limit) and anchor to the bottom;
|
|
// scrolling up lazy-loads older messages. `total`/`offset` tell the chat where the window sits.
|
|
useEffect(() => {
|
|
if (isNew) {
|
|
setSelected({ id: `new:${Date.now()}` });
|
|
return;
|
|
}
|
|
if (!sessionId) return;
|
|
if (selectedRef.current?.id === sessionId && selectedRef.current.resumeSessionId) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const detail = await client.get<ClaudeSessionDetail>(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`);
|
|
if (cancelled) return;
|
|
setActiveCwd(detail.cwd || null);
|
|
setSelected({
|
|
id: sessionId,
|
|
model: detail.model,
|
|
resumeSessionId: sessionId,
|
|
initialMessages: detail.messages as unknown as NonNullable<SelectedSession>['initialMessages'],
|
|
total: detail.total,
|
|
initialOffset: detail.offset,
|
|
});
|
|
} catch {
|
|
if (!cancelled) setSelected({ id: sessionId });
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId, isNew]);
|
|
|
|
return (
|
|
<div className="h-full w-full pt-2">
|
|
<WorkspaceView
|
|
workspace={workspace}
|
|
locked
|
|
mobilePanelId={mobilePanelId}
|
|
onMobilePanelChange={(id) => {
|
|
if (!id) navigate('/chat', { replace: true });
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|