* feat: add chat option for user message rendering mode * feat: add chat option to toggle sticky user header * feat(ui): overhaul context panel with reusable tabs and embedded session chat Enable parallel context workflows with persistent tabbed views and isolated session chat while reducing resize and background runtime overhead. * feat: polish context panel and git sidebar tabs Refined context panel tab behavior and visuals for smoother switching and resizing Reused the new tabs component in right sidebar and git sidebar with fit layout Improved git section spacing, selection controls, and bulk revert confirmation flow * feat: open diff files in editor at changed lines Add edit actions in diff views to open files at the first changed line Support per-file open-in-editor from All Files headers and icon-only action in single-file view Improve file jump UX with load-aware navigation and reduced visual blink during line targeting * fix: stabilize pill tabs and prevent git commit pathspec failures Unified sortable tab variants to match animated styling behavior with responsive spacing and cleaner sidebar chrome Fixed active tab pill measurement so size/position recalculates correctly when dropdowns reopen Commit API now filters stale file paths before staging to avoid pathspec errors on deleted files * fix: align user message action row spacing and hover behavior * fix: persist user message view preferences in settings Save plain-text and sticky-header toggles to settings.json when changed Restore both chat display preferences from settings.json on startup Validate and accept both preference fields in the settings API * fix: improve git and sidebar tab layout on mobile * fix: refine mobile user message action row spacing Show mobile user-message actions in a consistent external row for sticky and non-sticky modes Tune button row height and vertical position to match both mobile variants Reduce sticky-header gradient tail and tighten assistant gap after user messages * fix: improve chat action hover zones and mobile top shadow logic Expand desktop trigger area so user action buttons reveal across the full row Add sticky-header phantom hover row so inline actions appear from the whole button lane Hide chat top scroll shadow on mobile only when sticky user headers are enabled * fix: remove commit message input scrollbar flicker Added optional scrollbar class support to shared textarea wrapper. Disabled overlay scrollbar for Git commit message input. Kept auto-resize behavior while preventing one-line empty-state micro-scroll. * feat: make model provider groups collapsible in selector Add collapsible provider headers in the chat model dropdown Persist expanded/collapsed provider state across sessions Refine provider header UX with inline chevrons and no hover highlight * feat: arrange chat settings into a compact two-column layout Places User Message Rendering next to Mermaid Rendering. Places Diff Layout next to Diff View Mode. Reduces right-column spacing to better match other settings sections. * fix: show worktree branch edit controls in draft sessions Detect worktree mode from current directory when session metadata is not yet bound Enable immediate branch rename UI in Git sidebar without session switching * feat: add beta badge to side panel menu action
660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
import React from 'react';
|
|
import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import type { Message, Part } from '@opencode-ai/sdk/v2';
|
|
|
|
import { ChatInput } from './ChatInput';
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import ChatEmptyState from './ChatEmptyState';
|
|
import MessageList, { type MessageListHandle } from './MessageList';
|
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|
import { useChatScrollManager } from '@/hooks/useChatScrollManager';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { getMemoryLimits } from '@/stores/types/sessionTypes';
|
|
import { Button } from '@/components/ui/button';
|
|
import { ButtonSmall } from '@/components/ui/button-small';
|
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
|
import { TimelineDialog } from './TimelineDialog';
|
|
import type { PermissionRequest } from '@/types/permission';
|
|
import type { QuestionRequest } from '@/types/question';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
|
const EMPTY_PERMISSIONS: PermissionRequest[] = [];
|
|
const EMPTY_QUESTIONS: QuestionRequest[] = [];
|
|
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
|
|
|
const collectVisibleSessionIdsForBlockingRequests = (
|
|
sessions: Array<{ id: string; parentID?: string }> | undefined,
|
|
currentSessionId: string | null
|
|
): string[] => {
|
|
if (!currentSessionId) return [];
|
|
if (!Array.isArray(sessions) || sessions.length === 0) return [currentSessionId];
|
|
|
|
const current = sessions.find((session) => session.id === currentSessionId);
|
|
if (!current) return [currentSessionId];
|
|
|
|
// Opencode parity: when viewing a child session, permission/question prompts are handled in parent thread.
|
|
if (current.parentID) {
|
|
return [];
|
|
}
|
|
|
|
const childIds = sessions
|
|
.filter((session) => session.parentID === currentSessionId)
|
|
.map((session) => session.id);
|
|
|
|
return [currentSessionId, ...childIds];
|
|
};
|
|
|
|
const flattenBlockingRequests = <T extends { id: string }>(
|
|
source: Map<string, T[]>,
|
|
sessionIds: string[]
|
|
): T[] => {
|
|
if (sessionIds.length === 0) return [];
|
|
const seen = new Set<string>();
|
|
const result: T[] = [];
|
|
|
|
for (const sessionId of sessionIds) {
|
|
const entries = source.get(sessionId);
|
|
if (!entries || entries.length === 0) continue;
|
|
for (const entry of entries) {
|
|
if (seen.has(entry.id)) continue;
|
|
seen.add(entry.id);
|
|
result.push(entry);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
export const ChatContainer: React.FC = () => {
|
|
const {
|
|
currentSessionId,
|
|
isLoading,
|
|
loadMessages,
|
|
loadMoreMessages,
|
|
updateViewportAnchor,
|
|
openNewSessionDraft,
|
|
setCurrentSession,
|
|
trimToViewportWindow,
|
|
newSessionDraft,
|
|
} = useSessionStore(
|
|
useShallow((state) => ({
|
|
currentSessionId: state.currentSessionId,
|
|
isLoading: state.isLoading,
|
|
loadMessages: state.loadMessages,
|
|
loadMoreMessages: state.loadMoreMessages,
|
|
updateViewportAnchor: state.updateViewportAnchor,
|
|
openNewSessionDraft: state.openNewSessionDraft,
|
|
setCurrentSession: state.setCurrentSession,
|
|
trimToViewportWindow: state.trimToViewportWindow,
|
|
newSessionDraft: state.newSessionDraft,
|
|
}))
|
|
);
|
|
|
|
const { isSyncing, messageStreamStates, sessionMemoryStateMap } = useSessionStore(
|
|
useShallow((state) => ({
|
|
isSyncing: state.isSyncing,
|
|
messageStreamStates: state.messageStreamStates,
|
|
sessionMemoryStateMap: state.sessionMemoryState,
|
|
}))
|
|
);
|
|
|
|
const {
|
|
isTimelineDialogOpen,
|
|
setTimelineDialogOpen,
|
|
isExpandedInput,
|
|
stickyUserHeader,
|
|
} = useUIStore();
|
|
|
|
const sessionMessages = useSessionStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.messages.get(currentSessionId) ?? EMPTY_MESSAGES : EMPTY_MESSAGES),
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
|
|
const sessions = useSessionStore((state) => state.sessions);
|
|
|
|
const blockingRequestState = useSessionStore(
|
|
useShallow((state) => ({
|
|
sessions: state.sessions,
|
|
permissions: state.permissions,
|
|
questions: state.questions,
|
|
}))
|
|
);
|
|
|
|
const scopedSessionIds = React.useMemo(
|
|
() => collectVisibleSessionIdsForBlockingRequests(
|
|
blockingRequestState.sessions.map((session) => ({ id: session.id, parentID: session.parentID })),
|
|
currentSessionId,
|
|
),
|
|
[blockingRequestState.sessions, currentSessionId]
|
|
);
|
|
|
|
const sessionPermissions = React.useMemo(() => {
|
|
if (scopedSessionIds.length === 0) return EMPTY_PERMISSIONS;
|
|
return flattenBlockingRequests(blockingRequestState.permissions, scopedSessionIds);
|
|
}, [blockingRequestState.permissions, scopedSessionIds]);
|
|
|
|
const sessionQuestions = React.useMemo(() => {
|
|
if (scopedSessionIds.length === 0) return EMPTY_QUESTIONS;
|
|
return flattenBlockingRequests(blockingRequestState.questions, scopedSessionIds);
|
|
}, [blockingRequestState.questions, scopedSessionIds]);
|
|
|
|
const memoryState = useSessionStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.sessionMemoryState.get(currentSessionId) ?? null : null),
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
|
|
const streamingMessageId = useSessionStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.streamingMessageIds.get(currentSessionId) ?? null : null),
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
|
|
const sessionStatusForCurrent = useSessionStore(
|
|
React.useCallback(
|
|
(state) => (currentSessionId ? state.sessionStatus?.get(currentSessionId) ?? IDLE_SESSION_STATUS : IDLE_SESSION_STATUS),
|
|
[currentSessionId]
|
|
)
|
|
);
|
|
|
|
const hasSessionMessagesEntry = useSessionStore(
|
|
React.useCallback((state) => (currentSessionId ? state.messages.has(currentSessionId) : false), [currentSessionId])
|
|
);
|
|
|
|
const { isMobile } = useDeviceInfo();
|
|
const draftOpen = Boolean(newSessionDraft?.open);
|
|
const isDesktopExpandedInput = isExpandedInput && !isMobile;
|
|
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
|
|
|
const parentSession = React.useMemo(() => {
|
|
if (!currentSessionId) {
|
|
return null;
|
|
}
|
|
|
|
const current = sessions.find((session) => session.id === currentSessionId);
|
|
const parentID = current?.parentID;
|
|
if (!parentID) {
|
|
return null;
|
|
}
|
|
|
|
return sessions.find((session) => session.id === parentID) ?? null;
|
|
}, [currentSessionId, sessions]);
|
|
|
|
const handleReturnToParentSession = React.useCallback(() => {
|
|
if (!parentSession) {
|
|
return;
|
|
}
|
|
void setCurrentSession(parentSession.id);
|
|
}, [parentSession, setCurrentSession]);
|
|
|
|
const returnToParentButton = parentSession ? (
|
|
<ButtonSmall
|
|
type="button"
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={handleReturnToParentSession}
|
|
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
|
|
aria-label="Return to parent session"
|
|
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
|
|
>
|
|
<RiArrowLeftLine className="h-4 w-4" />
|
|
Parent
|
|
</ButtonSmall>
|
|
) : null;
|
|
|
|
React.useEffect(() => {
|
|
if (!currentSessionId && !draftOpen) {
|
|
openNewSessionDraft();
|
|
}
|
|
}, [currentSessionId, draftOpen, openNewSessionDraft]);
|
|
|
|
const [turnStart, setTurnStart] = React.useState(0);
|
|
const turnHandleRef = React.useRef<number | null>(null);
|
|
const turnIdleRef = React.useRef(false);
|
|
const initializedTurnStartSessionRef = React.useRef<string | null>(null);
|
|
const TURN_INIT = 5;
|
|
const TURN_BATCH = 8;
|
|
|
|
const userTurnIndexes = React.useMemo(() => {
|
|
const indexes: number[] = [];
|
|
for (let i = 0; i < sessionMessages.length; i += 1) {
|
|
const message = sessionMessages[i];
|
|
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
|
|
if (role === 'user') {
|
|
indexes.push(i);
|
|
}
|
|
}
|
|
return indexes;
|
|
}, [sessionMessages]);
|
|
|
|
const cancelTurnBackfill = React.useCallback(() => {
|
|
const handle = turnHandleRef.current;
|
|
if (handle === null) {
|
|
return;
|
|
}
|
|
turnHandleRef.current = null;
|
|
if (turnIdleRef.current && typeof window !== 'undefined' && typeof window.cancelIdleCallback === 'function') {
|
|
window.cancelIdleCallback(handle);
|
|
return;
|
|
}
|
|
if (typeof window !== 'undefined') {
|
|
window.clearTimeout(handle);
|
|
}
|
|
}, []);
|
|
|
|
const renderedSessionMessages = React.useMemo(() => {
|
|
if (turnStart <= 0 || userTurnIndexes.length === 0) {
|
|
return sessionMessages;
|
|
}
|
|
const startIndex = userTurnIndexes[turnStart] ?? 0;
|
|
return sessionMessages.slice(startIndex);
|
|
}, [sessionMessages, turnStart, userTurnIndexes]);
|
|
|
|
const backfillTurns = React.useCallback(() => {
|
|
if (turnStart <= 0) {
|
|
return;
|
|
}
|
|
|
|
const container = typeof document !== 'undefined'
|
|
? (document.querySelector('[data-scrollbar="chat"]') as HTMLDivElement | null)
|
|
: null;
|
|
const beforeTop = container?.scrollTop ?? null;
|
|
const beforeHeight = container?.scrollHeight ?? null;
|
|
|
|
setTurnStart((prev) => (prev - TURN_BATCH > 0 ? prev - TURN_BATCH : 0));
|
|
|
|
if (container && beforeTop !== null && beforeHeight !== null) {
|
|
window.requestAnimationFrame(() => {
|
|
const delta = container.scrollHeight - beforeHeight;
|
|
if (delta !== 0) {
|
|
container.scrollTop = beforeTop + delta;
|
|
}
|
|
});
|
|
}
|
|
}, [turnStart]);
|
|
|
|
const scheduleTurnBackfill = React.useCallback(() => {
|
|
if (turnHandleRef.current !== null || turnStart <= 0) {
|
|
return;
|
|
}
|
|
|
|
if (typeof window !== 'undefined' && typeof window.requestIdleCallback === 'function') {
|
|
turnIdleRef.current = true;
|
|
turnHandleRef.current = window.requestIdleCallback(() => {
|
|
turnHandleRef.current = null;
|
|
backfillTurns();
|
|
});
|
|
return;
|
|
}
|
|
|
|
turnIdleRef.current = false;
|
|
turnHandleRef.current = window.setTimeout(() => {
|
|
turnHandleRef.current = null;
|
|
backfillTurns();
|
|
}, 0);
|
|
}, [backfillTurns, turnStart]);
|
|
|
|
const sessionBlockingCards = React.useMemo(() => {
|
|
return [...sessionPermissions, ...sessionQuestions];
|
|
}, [sessionPermissions, sessionQuestions]);
|
|
|
|
const {
|
|
scrollRef,
|
|
handleMessageContentChange,
|
|
getAnimationHandlers,
|
|
showScrollButton,
|
|
scrollToBottom,
|
|
scrollToPosition,
|
|
isPinned,
|
|
} = useChatScrollManager({
|
|
currentSessionId,
|
|
sessionMessages: renderedSessionMessages,
|
|
streamingMessageId,
|
|
sessionMemoryState: sessionMemoryStateMap,
|
|
updateViewportAnchor,
|
|
isSyncing,
|
|
isMobile,
|
|
messageStreamStates,
|
|
sessionPermissions: sessionBlockingCards,
|
|
trimToViewportWindow,
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
cancelTurnBackfill();
|
|
if (!currentSessionId) {
|
|
initializedTurnStartSessionRef.current = null;
|
|
setTurnStart(0);
|
|
return;
|
|
}
|
|
|
|
if (initializedTurnStartSessionRef.current === currentSessionId) {
|
|
return;
|
|
}
|
|
|
|
if (sessionMessages.length === 0) {
|
|
setTurnStart(0);
|
|
return;
|
|
}
|
|
|
|
const turnCount = userTurnIndexes.length;
|
|
const start = turnCount > TURN_INIT ? turnCount - TURN_INIT : 0;
|
|
setTurnStart(start);
|
|
initializedTurnStartSessionRef.current = currentSessionId;
|
|
}, [cancelTurnBackfill, currentSessionId, sessionMessages.length, userTurnIndexes.length]);
|
|
|
|
const isSessionActive = sessionStatusForCurrent.type === 'busy' || sessionStatusForCurrent.type === 'retry';
|
|
|
|
React.useEffect(() => {
|
|
if (isSessionActive) {
|
|
cancelTurnBackfill();
|
|
return;
|
|
}
|
|
scheduleTurnBackfill();
|
|
return () => {
|
|
cancelTurnBackfill();
|
|
};
|
|
}, [cancelTurnBackfill, isSessionActive, scheduleTurnBackfill, turnStart]);
|
|
|
|
const hasMoreAbove = React.useMemo(() => {
|
|
if (!memoryState) {
|
|
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
|
|
}
|
|
if (memoryState.historyComplete === true) {
|
|
return false;
|
|
}
|
|
if (memoryState.hasMoreAbove) {
|
|
return true;
|
|
}
|
|
if (memoryState.historyComplete === false) {
|
|
return true;
|
|
}
|
|
|
|
// Backward compatibility: older persisted sessions may miss history flags.
|
|
if (memoryState.hasMoreAbove === undefined && memoryState.historyComplete === undefined) {
|
|
return sessionMessages.length >= getMemoryLimits().HISTORICAL_MESSAGES;
|
|
}
|
|
|
|
return false;
|
|
}, [memoryState, sessionMessages.length]);
|
|
|
|
const hasHistoryMetadata = React.useMemo(() => {
|
|
if (!memoryState) {
|
|
return false;
|
|
}
|
|
return memoryState.hasMoreAbove !== undefined || memoryState.historyComplete !== undefined;
|
|
}, [memoryState]);
|
|
const [isLoadingOlder, setIsLoadingOlder] = React.useState(false);
|
|
React.useEffect(() => {
|
|
setIsLoadingOlder(false);
|
|
}, [currentSessionId]);
|
|
|
|
const handleLoadOlder = React.useCallback(async () => {
|
|
if (!currentSessionId || isLoadingOlder) {
|
|
return;
|
|
}
|
|
|
|
cancelTurnBackfill();
|
|
setTurnStart(0);
|
|
|
|
const container = scrollRef.current;
|
|
const anchor = messageListRef.current?.captureViewportAnchor() ?? null;
|
|
const prevHeight = container?.scrollHeight ?? null;
|
|
const prevTop = container?.scrollTop ?? null;
|
|
|
|
setIsLoadingOlder(true);
|
|
void loadMoreMessages(currentSessionId, 'up')
|
|
.then(() => {
|
|
const restored = anchor ? (messageListRef.current?.restoreViewportAnchor(anchor) ?? false) : false;
|
|
if (!restored && container && prevHeight !== null && prevTop !== null) {
|
|
const heightDiff = container.scrollHeight - prevHeight;
|
|
scrollToPosition(prevTop + heightDiff, { instant: true });
|
|
}
|
|
})
|
|
.finally(() => {
|
|
setIsLoadingOlder(false);
|
|
});
|
|
}, [cancelTurnBackfill, currentSessionId, isLoadingOlder, loadMoreMessages, scrollRef, scrollToPosition]);
|
|
|
|
const handleRenderEarlier = React.useCallback(() => {
|
|
cancelTurnBackfill();
|
|
setTurnStart(0);
|
|
}, [cancelTurnBackfill]);
|
|
|
|
// Scroll to a specific message by ID (for timeline dialog)
|
|
const scrollToMessage = React.useCallback((messageId: string) => {
|
|
if (messageListRef.current?.scrollToMessageId(messageId, { behavior: 'smooth' })) {
|
|
return;
|
|
}
|
|
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
|
|
// Find the message element by looking for data-message-id attribute
|
|
const messageElement = container.querySelector(`[data-message-id="${messageId}"]`) as HTMLElement;
|
|
if (messageElement) {
|
|
// Scroll to the message with some padding (50px from top)
|
|
const containerRect = container.getBoundingClientRect();
|
|
const messageRect = messageElement.getBoundingClientRect();
|
|
const offset = 50;
|
|
|
|
const scrollTop = messageRect.top - containerRect.top + container.scrollTop - offset;
|
|
container.scrollTo({
|
|
top: scrollTop,
|
|
behavior: 'smooth'
|
|
});
|
|
}
|
|
}, [scrollRef]);
|
|
|
|
React.useEffect(() => {
|
|
if (!currentSessionId) {
|
|
return;
|
|
}
|
|
|
|
const hasSessionMessages = hasSessionMessagesEntry;
|
|
if (hasSessionMessages && hasHistoryMetadata) {
|
|
return;
|
|
}
|
|
|
|
const load = async () => {
|
|
await loadMessages(currentSessionId).finally(() => {
|
|
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
|
const isActivePhase = statusType === 'busy' || statusType === 'retry';
|
|
const shouldSkipScroll = isActivePhase && isPinned;
|
|
|
|
if (!shouldSkipScroll) {
|
|
if (typeof window === 'undefined') {
|
|
scrollToBottom({ instant: true });
|
|
} else {
|
|
window.requestAnimationFrame(() => {
|
|
scrollToBottom({ instant: true });
|
|
});
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
void load();
|
|
}, [currentSessionId, hasHistoryMetadata, hasSessionMessagesEntry, isPinned, loadMessages, scrollToBottom, sessionMessages.length, sessionStatusForCurrent.type]);
|
|
|
|
if (!currentSessionId && !draftOpen) {
|
|
return (
|
|
<div
|
|
className="flex flex-col h-full bg-background"
|
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
|
>
|
|
<ChatEmptyState />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!currentSessionId && draftOpen) {
|
|
return (
|
|
<div
|
|
className="relative flex flex-col h-full bg-background transform-gpu"
|
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
|
>
|
|
{!isDesktopExpandedInput ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<ChatEmptyState showDraftContext />
|
|
</div>
|
|
) : null}
|
|
<div
|
|
className={cn(
|
|
'relative z-10',
|
|
isDesktopExpandedInput
|
|
? 'flex-1 min-h-0 bg-background'
|
|
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
|
)}
|
|
>
|
|
<ChatInput scrollToBottom={scrollToBottom} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!currentSessionId) {
|
|
return null;
|
|
}
|
|
|
|
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
|
|
const hasMessagesEntry = hasSessionMessagesEntry;
|
|
if (!hasMessagesEntry) {
|
|
return (
|
|
<div
|
|
className="relative flex flex-col h-full bg-background gap-0"
|
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
|
>
|
|
{returnToParentButton}
|
|
<div className="flex-1 overflow-y-auto p-4 bg-background">
|
|
<div className="chat-message-column space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex gap-3 p-4">
|
|
<Skeleton className="h-8 w-8 rounded-full" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-24" />
|
|
<Skeleton className="h-20 w-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<ChatInput scrollToBottom={scrollToBottom} />
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (sessionMessages.length === 0 && !streamingMessageId) {
|
|
return (
|
|
<div
|
|
className="relative flex flex-col h-full bg-background transform-gpu"
|
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
|
>
|
|
{returnToParentButton}
|
|
{!isDesktopExpandedInput ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<ChatEmptyState />
|
|
</div>
|
|
) : null}
|
|
<div
|
|
className={cn(
|
|
'relative z-10',
|
|
isDesktopExpandedInput
|
|
? 'flex-1 min-h-0 bg-background'
|
|
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
|
)}
|
|
>
|
|
<ChatInput scrollToBottom={scrollToBottom} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative flex flex-col h-full bg-background"
|
|
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
|
|
>
|
|
{returnToParentButton}
|
|
<div
|
|
className={cn(
|
|
'relative min-h-0',
|
|
isDesktopExpandedInput
|
|
? 'absolute inset-0 opacity-0 pointer-events-none'
|
|
: 'flex-1'
|
|
)}
|
|
aria-hidden={isDesktopExpandedInput}
|
|
>
|
|
<div className="absolute inset-0">
|
|
<ScrollShadow
|
|
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
|
|
ref={scrollRef}
|
|
observeMutations={false}
|
|
hideTopShadow={isMobile && stickyUserHeader}
|
|
data-scroll-shadow="true"
|
|
data-scrollbar="chat"
|
|
>
|
|
<div className="relative z-0 min-h-full">
|
|
<MessageList
|
|
ref={messageListRef}
|
|
messages={renderedSessionMessages}
|
|
permissions={sessionPermissions}
|
|
questions={sessionQuestions}
|
|
onMessageContentChange={handleMessageContentChange}
|
|
getAnimationHandlers={getAnimationHandlers}
|
|
hasMoreAbove={hasMoreAbove}
|
|
isLoadingOlder={isLoadingOlder}
|
|
onLoadOlder={handleLoadOlder}
|
|
hasRenderEarlier={turnStart > 0}
|
|
onRenderEarlier={handleRenderEarlier}
|
|
scrollToBottom={scrollToBottom}
|
|
scrollRef={scrollRef}
|
|
/>
|
|
</div>
|
|
</ScrollShadow>
|
|
<OverlayScrollbar containerRef={scrollRef} />
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className={cn(
|
|
'relative z-10',
|
|
isDesktopExpandedInput
|
|
? 'flex-1 min-h-0 bg-background'
|
|
: 'bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80'
|
|
)}
|
|
>
|
|
{!isDesktopExpandedInput && showScrollButton && sessionMessages.length > 0 && (
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => scrollToBottom({ force: true })}
|
|
className="rounded-full h-8 w-8 p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
|
|
aria-label="Scroll to bottom"
|
|
>
|
|
|
|
<RiArrowDownLine className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
<ChatInput scrollToBottom={scrollToBottom} />
|
|
</div>
|
|
|
|
<TimelineDialog
|
|
open={isTimelineDialogOpen}
|
|
onOpenChange={setTimelineDialogOpen}
|
|
onScrollToMessage={scrollToMessage}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|