Sending a message now parks that message near the top of the viewport and streams the reply into reserved end space below it, instead of jumping to the bottom and chasing it. - swap @tanstack/react-virtual for @legendapp/list in the chat timeline; the streaming tail becomes a normal list row rather than a separately rendered block, so one component owns the scroll position - add timelineScrollAnchoring: pure anchored-turn geometry plus the three scroll modes (following-end / anchoring-new-turn / free-scrolling) - replace useChatAutoFollow with useChatTimelineScroll, which opts out of automatic movement on real gestures via a generation counter instead of the timer windows the old implementation needed to recognise its own writes - move the load-older button, question/permission cards, recap, status row and bottom spacer into the list header/footer, since the list owns its container - extract useScrollShadow so the shadows can attach to that container maintainScrollAtEnd and maintainVisibleContentPosition replace the manual prepend anchor-hold and the mobile quiet-window prepend deferral. Validated: workspace type-check, lint, web build, ui tests per file. Scroll behaviour itself is unverified and needs manual testing on web, desktop and iOS.
1438 lines
61 KiB
TypeScript
1438 lines
61 KiB
TypeScript
import React from 'react';
|
|
import type { Message, Part, Session } from '@opencode-ai/sdk/v2';
|
|
import type { PermissionRequest } from '@/types/permission';
|
|
import type { QuestionRequest } from '@/types/question';
|
|
|
|
import { ChatInput } from './ChatInput';
|
|
import { DraftPresetChips } from './DraftPresetChips';
|
|
import { useInputStore } from '@/sync/input-store';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import ChatEmptyState from './ChatEmptyState';
|
|
import { useGlobalSyncStore } from '@/sync/global-sync-store';
|
|
import MessageList, { type MessageListHandle } from './MessageList';
|
|
import { PermissionCard } from './PermissionCard';
|
|
import { QuestionCard } from './QuestionCard';
|
|
import { hasActiveQuestionToolInCurrentTurn, recoverPendingQuestionWithRetry } from '@/sync/question-recovery';
|
|
import { StatusRowContainer } from './StatusRowContainer';
|
|
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
|
|
import ScrollToBottomButton from './components/ScrollToBottomButton';
|
|
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
|
|
import { useScrollShadow } from '@/components/ui/useScrollShadow';
|
|
import { useChatTimelineScroll, type AnimationHandlers, type ContentChangeReason, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
|
|
import { useChatTimelineController } from './hooks/useChatTimelineController';
|
|
import { TimelineDialog } from './TimelineDialog';
|
|
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
|
|
import { useChatSurfaceMode } from './useChatSurfaceMode';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { Button } from '@/components/ui/button';
|
|
import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar';
|
|
import { Icon } from "@/components/icon/Icon";
|
|
import { cn, formatDirectoryName } from '@/lib/utils';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
|
|
// New sync system imports
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useStreamingStore } from '@/sync/streaming';
|
|
import {
|
|
useSessionMessageCount,
|
|
useSessionMessageRecords,
|
|
useSessionMessageLoadState,
|
|
useSyncDirectory,
|
|
useSessionRenderable,
|
|
useSessionStatus,
|
|
useScopedBlockingPermissions,
|
|
useScopedBlockingQuestions,
|
|
useParentSession,
|
|
useSession,
|
|
} from '@/sync/sync-context';
|
|
import { useSync } from '@/sync/use-sync';
|
|
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
import { WorkStatusPanel } from './work-status/WorkStatusPanel';
|
|
import { useWorkStatusVisibility } from './work-status/useWorkStatusVisibility';
|
|
import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/contextPanelEmbeddedChat';
|
|
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
|
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
|
import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge';
|
|
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
|
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
|
|
import { isChatDirectoryPath } from '@/lib/chatDirectories';
|
|
|
|
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
|
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
|
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
|
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
|
const DRAFT_EXIT_DURATION_MS = 120;
|
|
const COMPOSER_MOVE_DURATION_MS = 180;
|
|
const CHAT_SCROLL_STYLE = {
|
|
overflowAnchor: 'none',
|
|
overscrollBehavior: 'contain',
|
|
overscrollBehaviorY: 'contain',
|
|
} as const;
|
|
const CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR = [
|
|
'a[href]',
|
|
'button',
|
|
'input',
|
|
'select',
|
|
'textarea',
|
|
'[contenteditable="true"]',
|
|
'[role="button"]',
|
|
'[role="combobox"]',
|
|
'[role="dialog"]',
|
|
'[role="listbox"]',
|
|
'[role="menu"]',
|
|
'[role="menuitem"]',
|
|
'[role="option"]',
|
|
'[role="textbox"]',
|
|
'[data-radix-popper-content-wrapper]',
|
|
].join(',');
|
|
type SessionMessageRecord = { info: Message; parts: Part[] };
|
|
|
|
const isHTMLElement = (target: EventTarget | null): target is HTMLElement => {
|
|
return target instanceof HTMLElement;
|
|
};
|
|
|
|
const shouldIgnoreChatNavigationTarget = (target: EventTarget | null): boolean => {
|
|
if (!isHTMLElement(target)) {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(target.closest(CHAT_NAVIGATION_IGNORED_TARGET_SELECTOR));
|
|
};
|
|
|
|
const shouldIgnoreChatNavigationForFocus = (activeElement: Element | null, scrollContainer: HTMLElement | null): boolean => {
|
|
if (typeof document === 'undefined') {
|
|
return true;
|
|
}
|
|
|
|
if (!activeElement || activeElement === document.body || activeElement === document.documentElement) {
|
|
return true;
|
|
}
|
|
|
|
if (shouldIgnoreChatNavigationTarget(activeElement)) {
|
|
return true;
|
|
}
|
|
|
|
return !scrollContainer?.contains(activeElement);
|
|
};
|
|
|
|
const hasBlockingChatOverlay = (): boolean => {
|
|
const {
|
|
isAboutDialogOpen,
|
|
isCommandPaletteOpen,
|
|
isHelpDialogOpen,
|
|
isImagePreviewOpen,
|
|
isMultiRunLauncherOpen,
|
|
isSessionSwitcherOpen,
|
|
isSettingsDialogOpen,
|
|
} = useUIStore.getState();
|
|
|
|
return isAboutDialogOpen
|
|
|| isCommandPaletteOpen
|
|
|| isHelpDialogOpen
|
|
|| isImagePreviewOpen
|
|
|| isMultiRunLauncherOpen
|
|
|| isSessionSwitcherOpen
|
|
|| isSettingsDialogOpen;
|
|
};
|
|
|
|
type HydratingToolSkeletonRow = {
|
|
id: string;
|
|
titleWidth: string;
|
|
detailWidth: string;
|
|
};
|
|
|
|
type ChatViewportProps = {
|
|
currentSessionId: string;
|
|
currentSessionKey: string;
|
|
isDesktopExpandedInput: boolean;
|
|
isMobile: boolean;
|
|
directory?: string;
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
messageListRef: React.RefObject<MessageListHandle | null>;
|
|
registerList: (list: TimelineListHandle | null) => void;
|
|
anchorMessageId: string | null;
|
|
onAnchorReady: (messageId: string, anchorIndex: number) => void;
|
|
onAnchorSizeChanged: (messageId: string) => void;
|
|
composerOverlayHeight: number;
|
|
onIsAtEndChange: (isAtEnd: boolean) => void;
|
|
onTimelineDataChange: () => void;
|
|
pendingRevealWork: boolean;
|
|
renderedMessages: SessionMessageRecord[];
|
|
isLoadingOlder: boolean;
|
|
sessionIsWorking: boolean;
|
|
streamingMessageId: string | null;
|
|
activeStreamingPhase: import('./message/types').StreamPhase | null;
|
|
retryOverlay: {
|
|
sessionId: string;
|
|
message: string;
|
|
confirmedAt?: number;
|
|
fallbackTimestamp?: number;
|
|
} | null;
|
|
handleMessageContentChange: (reason?: ContentChangeReason) => void;
|
|
getAnimationHandlers: (messageId: string) => AnimationHandlers;
|
|
scrollToBottom: () => void;
|
|
sessionQuestions: QuestionRequest[];
|
|
sessionPermissions: PermissionRequest[];
|
|
isProgrammaticFollowActive: boolean;
|
|
showLoadOlderButton: boolean;
|
|
onLoadOlder: () => void;
|
|
turnIds: string[];
|
|
activeTurnId: string | null;
|
|
onSelectTurn: (turnId: string) => void;
|
|
showPromptNavigator: boolean;
|
|
canLoadEarlierPrompts: boolean;
|
|
isLoadingOlderPrompts: boolean;
|
|
onLoadEarlierPrompts: () => void;
|
|
};
|
|
|
|
const ChatViewport = React.memo(({
|
|
currentSessionId,
|
|
currentSessionKey,
|
|
isDesktopExpandedInput,
|
|
isMobile,
|
|
directory,
|
|
scrollRef,
|
|
messageListRef,
|
|
registerList,
|
|
anchorMessageId,
|
|
onAnchorReady,
|
|
onAnchorSizeChanged,
|
|
composerOverlayHeight,
|
|
onIsAtEndChange,
|
|
onTimelineDataChange,
|
|
pendingRevealWork,
|
|
renderedMessages,
|
|
isLoadingOlder,
|
|
sessionIsWorking,
|
|
streamingMessageId,
|
|
activeStreamingPhase,
|
|
retryOverlay,
|
|
handleMessageContentChange,
|
|
getAnimationHandlers,
|
|
scrollToBottom,
|
|
sessionQuestions,
|
|
sessionPermissions,
|
|
isProgrammaticFollowActive,
|
|
showLoadOlderButton,
|
|
onLoadOlder,
|
|
turnIds,
|
|
activeTurnId,
|
|
onSelectTurn,
|
|
showPromptNavigator,
|
|
canLoadEarlierPrompts,
|
|
isLoadingOlderPrompts,
|
|
onLoadEarlierPrompts,
|
|
}: ChatViewportProps) => {
|
|
const { t } = useI18n();
|
|
const promptPreviewsByTurnIdRef = React.useRef<Map<string, Part[]>>(new Map());
|
|
// Cache normalized parts per source array so unchanged messages keep the
|
|
// same reference and the memo below can bail out to the previous map.
|
|
const normalizedPromptPartsCache = React.useRef(new WeakMap<Part[], Part[]>());
|
|
// Shell-mode prompts show their extracted command; cache by message id so
|
|
// the parts array reference is stable while the command is unchanged.
|
|
const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>());
|
|
const shellPreviewSessionRef = React.useRef(currentSessionId);
|
|
if (shellPreviewSessionRef.current !== currentSessionId) {
|
|
shellPreviewSessionRef.current = currentSessionId;
|
|
shellPreviewCache.current.clear();
|
|
}
|
|
const promptPreviewsByTurnId = React.useMemo(() => {
|
|
const next = new Map<string, Part[]>();
|
|
for (let index = 0; index < renderedMessages.length; index += 1) {
|
|
const message = renderedMessages[index];
|
|
if (message.info.role !== 'user') {
|
|
continue;
|
|
}
|
|
if (isUserShellMarkerMessage(message)) {
|
|
const command = findShellCommandForMessage(renderedMessages, index) ?? '';
|
|
const cached = shellPreviewCache.current.get(message.info.id);
|
|
if (cached && cached.command === command) {
|
|
next.set(message.info.id, cached.parts);
|
|
} else {
|
|
const parts = [{ type: 'text', text: command ? `$ ${command}` : '/shell' } as Part];
|
|
shellPreviewCache.current.set(message.info.id, { command, parts });
|
|
next.set(message.info.id, parts);
|
|
}
|
|
continue;
|
|
}
|
|
// Other fully synthetic user messages (loop continuations,
|
|
// plan-mode injections) are not prompts the user typed — keep
|
|
// them out of the navigator entirely.
|
|
if (isFullySyntheticMessage(message.parts)) {
|
|
continue;
|
|
}
|
|
let displayParts = normalizedPromptPartsCache.current.get(message.parts);
|
|
if (!displayParts) {
|
|
displayParts = normalizeUserDisplayParts(message.parts);
|
|
normalizedPromptPartsCache.current.set(message.parts, displayParts);
|
|
}
|
|
if (displayParts.length === 0) {
|
|
continue;
|
|
}
|
|
next.set(message.info.id, displayParts);
|
|
}
|
|
const prev = promptPreviewsByTurnIdRef.current;
|
|
if (prev.size === next.size) {
|
|
let unchanged = true;
|
|
for (const [id, parts] of next) {
|
|
if (prev.get(id) !== parts) {
|
|
unchanged = false;
|
|
break;
|
|
}
|
|
}
|
|
if (unchanged) {
|
|
return prev;
|
|
}
|
|
}
|
|
promptPreviewsByTurnIdRef.current = next;
|
|
return next;
|
|
}, [renderedMessages]);
|
|
// Only real (non-synthetic) prompts become rail entries; selection still
|
|
// targets the same turn anchors as the timeline.
|
|
const promptTurnIds = React.useMemo(
|
|
() => turnIds.filter((id) => promptPreviewsByTurnId.has(id)),
|
|
[promptPreviewsByTurnId, turnIds],
|
|
);
|
|
// If the viewport sits in a filtered-out (synthetic) turn, treat the
|
|
// nearest preceding real prompt as active so the rail doesn't jump.
|
|
const railActiveTurnId = React.useMemo(() => {
|
|
if (!activeTurnId || promptPreviewsByTurnId.has(activeTurnId)) {
|
|
return activeTurnId;
|
|
}
|
|
const activeIndex = turnIds.indexOf(activeTurnId);
|
|
for (let index = activeIndex - 1; index >= 0; index -= 1) {
|
|
const turnId = turnIds[index];
|
|
if (promptPreviewsByTurnId.has(turnId)) {
|
|
return turnId;
|
|
}
|
|
}
|
|
return null;
|
|
}, [activeTurnId, promptPreviewsByTurnId, turnIds]);
|
|
const focusScrollContainer = React.useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
if (event.defaultPrevented || shouldIgnoreChatNavigationTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
if (typeof window !== 'undefined' && window.getSelection()?.type === 'Range') {
|
|
return;
|
|
}
|
|
|
|
scrollRef.current?.focus({ preventScroll: true });
|
|
}, [scrollRef]);
|
|
|
|
// Everything that used to sit beside the list inside the scroll container
|
|
// now renders as the list's header/footer, so it keeps scrolling with the
|
|
// rows exactly as before.
|
|
const listHeader = React.useMemo(() => (
|
|
showLoadOlderButton ? (
|
|
<div className="flex justify-center pt-3 pb-1">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={onLoadOlder}
|
|
disabled={isLoadingOlder}
|
|
>
|
|
{isLoadingOlder && (
|
|
<Icon name="loader-4" className="size-4 animate-spin" />
|
|
)}
|
|
{t('chat.history.loadOlder')}
|
|
</Button>
|
|
</div>
|
|
) : null
|
|
), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]);
|
|
|
|
const listFooter = React.useMemo(() => (
|
|
<>
|
|
{(sessionQuestions.length > 0 || sessionPermissions.length > 0) && (
|
|
<div>
|
|
{sessionQuestions.map((question) => (
|
|
<QuestionCard key={question.id} question={question} />
|
|
))}
|
|
{sessionPermissions.map((permission) => (
|
|
<PermissionCard key={permission.id} permission={permission} />
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<SessionRecapNote sessionId={currentSessionId} directory={directory} isMobile={isMobile} />
|
|
|
|
<div className="mb-3">
|
|
<StatusRowContainer />
|
|
</div>
|
|
|
|
<div className="flex-shrink-0" style={{ height: isMobile ? '40px' : '10vh' }} aria-hidden="true" />
|
|
</>
|
|
), [currentSessionId, directory, isMobile, sessionPermissions, sessionQuestions]);
|
|
|
|
const scrollContainerProps = React.useMemo(() => ({
|
|
className: 'absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target',
|
|
style: CHAT_SCROLL_STYLE,
|
|
tabIndex: 0,
|
|
onClick: focusScrollContainer,
|
|
'data-scrollbar': 'chat',
|
|
'data-scroll-shadow': 'true',
|
|
'data-orientation': 'vertical',
|
|
}), [focusScrollContainer]);
|
|
|
|
return (
|
|
<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">
|
|
<MessageList
|
|
key={currentSessionKey}
|
|
ref={messageListRef}
|
|
sessionKey={currentSessionId}
|
|
disableStaging={pendingRevealWork}
|
|
messages={renderedMessages}
|
|
sessionIsWorking={sessionIsWorking}
|
|
activeStreamingMessageId={streamingMessageId}
|
|
activeStreamingPhase={activeStreamingPhase}
|
|
retryOverlay={retryOverlay}
|
|
onMessageContentChange={handleMessageContentChange}
|
|
getAnimationHandlers={getAnimationHandlers}
|
|
isLoadingOlder={isLoadingOlder}
|
|
scrollToBottom={scrollToBottom}
|
|
directory={directory}
|
|
registerList={registerList}
|
|
anchorMessageId={anchorMessageId}
|
|
onAnchorReady={onAnchorReady}
|
|
onAnchorSizeChanged={onAnchorSizeChanged}
|
|
composerOverlayHeight={composerOverlayHeight}
|
|
onIsAtEndChange={onIsAtEndChange}
|
|
onTimelineDataChange={onTimelineDataChange}
|
|
listHeader={listHeader}
|
|
listFooter={listFooter}
|
|
scrollContainerProps={scrollContainerProps}
|
|
/>
|
|
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
|
|
{showPromptNavigator && promptTurnIds.length >= 2 ? (
|
|
<PromptNavigatorRail
|
|
turnIds={promptTurnIds}
|
|
previewsByTurnId={promptPreviewsByTurnId}
|
|
activeTurnId={railActiveTurnId}
|
|
onSelectTurn={onSelectTurn}
|
|
canLoadEarlier={canLoadEarlierPrompts}
|
|
isLoadingOlder={isLoadingOlderPrompts}
|
|
onLoadEarlier={onLoadEarlierPrompts}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}, (prev, next) => {
|
|
return prev.currentSessionId === next.currentSessionId
|
|
&& prev.currentSessionKey === next.currentSessionKey
|
|
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
|
&& prev.isMobile === next.isMobile
|
|
&& prev.directory === next.directory
|
|
&& prev.scrollRef === next.scrollRef
|
|
&& prev.messageListRef === next.messageListRef
|
|
&& prev.pendingRevealWork === next.pendingRevealWork
|
|
&& prev.renderedMessages === next.renderedMessages
|
|
&& prev.isLoadingOlder === next.isLoadingOlder
|
|
&& prev.sessionIsWorking === next.sessionIsWorking
|
|
&& prev.streamingMessageId === next.streamingMessageId
|
|
&& prev.activeStreamingPhase === next.activeStreamingPhase
|
|
&& prev.retryOverlay === next.retryOverlay
|
|
&& prev.handleMessageContentChange === next.handleMessageContentChange
|
|
&& prev.getAnimationHandlers === next.getAnimationHandlers
|
|
&& prev.scrollToBottom === next.scrollToBottom
|
|
&& prev.sessionQuestions === next.sessionQuestions
|
|
&& prev.sessionPermissions === next.sessionPermissions
|
|
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
|
|
&& prev.showLoadOlderButton === next.showLoadOlderButton
|
|
&& prev.onLoadOlder === next.onLoadOlder
|
|
&& prev.turnIds === next.turnIds
|
|
&& prev.activeTurnId === next.activeTurnId
|
|
&& prev.onSelectTurn === next.onSelectTurn
|
|
&& prev.showPromptNavigator === next.showPromptNavigator
|
|
&& prev.canLoadEarlierPrompts === next.canLoadEarlierPrompts
|
|
&& prev.isLoadingOlderPrompts === next.isLoadingOlderPrompts
|
|
&& prev.onLoadEarlierPrompts === next.onLoadEarlierPrompts;
|
|
});
|
|
|
|
ChatViewport.displayName = 'ChatViewport';
|
|
|
|
const HYDRATING_SKELETON_ITEMS: Array<{
|
|
id: number;
|
|
toolRows: HydratingToolSkeletonRow[];
|
|
textWidths: [string, string, string];
|
|
}> = [
|
|
{
|
|
id: 1,
|
|
toolRows: [
|
|
{ id: 'search', titleWidth: 'w-24', detailWidth: 'w-52' },
|
|
{ id: 'read', titleWidth: 'w-20', detailWidth: 'w-36' },
|
|
{ id: 'edit', titleWidth: 'w-24', detailWidth: 'w-64' },
|
|
],
|
|
textWidths: ['w-24', 'w-[92%]', 'w-[78%]'],
|
|
},
|
|
{
|
|
id: 2,
|
|
toolRows: [
|
|
{ id: 'read', titleWidth: 'w-20', detailWidth: 'w-40' },
|
|
{ id: 'search', titleWidth: 'w-24', detailWidth: 'w-48' },
|
|
],
|
|
textWidths: ['w-20', 'w-[88%]', 'w-[70%]'],
|
|
},
|
|
{
|
|
id: 3,
|
|
toolRows: [
|
|
{ id: 'shell', titleWidth: 'w-28', detailWidth: 'w-44' },
|
|
{ id: 'edit', titleWidth: 'w-24', detailWidth: 'w-56' },
|
|
],
|
|
textWidths: ['w-24', 'w-[84%]', 'w-[64%]'],
|
|
},
|
|
];
|
|
|
|
const ReadOnlyPromptBanner: React.FC = () => {
|
|
const { t } = useI18n();
|
|
|
|
return (
|
|
<div className="w-full py-3">
|
|
<div className="chat-input-column">
|
|
<div className="rounded-2xl border border-border/70 bg-[var(--surface-background)] px-4 py-3 text-center typography-ui-label text-muted-foreground">
|
|
{t('chat.container.readOnlySubagentPromptBanner')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const getProjectDisplayLabel = (project: { label?: string; path: string }): string => {
|
|
const label = project.label?.trim();
|
|
return label || formatDirectoryName(project.path);
|
|
};
|
|
|
|
const renderDraftTitle = (title: string, projectLabel: string | null): React.ReactNode => {
|
|
if (!projectLabel) return title;
|
|
const projectIndex = title.indexOf(projectLabel);
|
|
if (projectIndex === -1) return title;
|
|
|
|
return (
|
|
<>
|
|
{title.slice(0, projectIndex)}
|
|
<span className="font-medium">{projectLabel}</span>
|
|
{title.slice(projectIndex + projectLabel.length)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => {
|
|
const { t } = useI18n();
|
|
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
|
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
|
const projectLabel = useProjectsStore(React.useCallback((state) => {
|
|
if (draftTarget === 'chat') return null;
|
|
const projectId = selectedProjectId ?? state.activeProjectId;
|
|
const project = (projectId
|
|
? state.projects.find((candidate) => candidate.id === projectId)
|
|
: null) ?? state.projects[0] ?? null;
|
|
return project ? getProjectDisplayLabel(project) : null;
|
|
}, [draftTarget, selectedProjectId]));
|
|
|
|
return (
|
|
<div className={cn(
|
|
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
|
|
exiting && 'pointer-events-none opacity-0',
|
|
)}>
|
|
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
|
{renderDraftTitle(
|
|
projectLabel
|
|
? t('chat.emptyState.draftTitleWithProject', { project: projectLabel })
|
|
: t('chat.emptyState.draftTitle'),
|
|
projectLabel,
|
|
)}
|
|
</h1>
|
|
<DraftPresetChips
|
|
onSubmit={(starter) => useInputStore.getState().requestPresetSubmit(starter.submitText, starter.ref.type)}
|
|
className="oc-draft-starters mt-8 max-w-md"
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
type ChatContainerProps = {
|
|
active?: boolean;
|
|
/**
|
|
* When set, controls message-history reads and session-message loads
|
|
* independently of `active`. Defaults to `active`. Embedded session-chat
|
|
* panels pass `true` so a delayed/lost visibility handshake cannot hide
|
|
* an already-materialized transcript (leaving only the working-status
|
|
* row — issue #2903).
|
|
*/
|
|
messagesEnabled?: boolean;
|
|
autoOpenDraft?: boolean;
|
|
readOnly?: boolean;
|
|
initialAllowPromptingSubagentSessions?: boolean;
|
|
};
|
|
|
|
export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|
active = true,
|
|
messagesEnabled: messagesEnabledProp,
|
|
autoOpenDraft = true,
|
|
readOnly = false,
|
|
initialAllowPromptingSubagentSessions,
|
|
}) => {
|
|
const messagesEnabled = messagesEnabledProp ?? active;
|
|
const { t } = useI18n();
|
|
// Session UI state
|
|
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
|
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
|
|
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
|
|
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
|
|
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
|
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
|
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
|
|
|
// Sync actions
|
|
const sync = useSync();
|
|
const syncDirectory = useSyncDirectory();
|
|
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
|
|
const currentSessionKey = currentSessionId
|
|
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
|
|
: null;
|
|
const ensureSessionRenderable = React.useCallback(
|
|
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
|
|
[effectiveSessionDirectory, sync],
|
|
);
|
|
const loadMoreMessages = React.useCallback(
|
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId, effectiveSessionDirectory),
|
|
[effectiveSessionDirectory, sync],
|
|
);
|
|
|
|
// UI store
|
|
const isExpandedInput = useUIStore((state) => state.isExpandedInput);
|
|
const stickyUserHeader = useUIStore((state) => state.stickyUserHeader);
|
|
const promptNavigatorEnabled = useUIStore((state) => state.promptNavigatorEnabled);
|
|
const allowPromptingSubagentSessions = useUIStore((state) => state.allowPromptingSubagentSessions);
|
|
const [embeddedAllowPrompting, setEmbeddedAllowPrompting] = React.useState(initialAllowPromptingSubagentSessions);
|
|
const isTimelineDialogOpen = useUIStore((s) => s.isTimelineDialogOpen);
|
|
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
|
|
|
|
// Streaming state
|
|
const streamingMessageId = useStreamingStore(
|
|
React.useCallback(
|
|
(s) => (currentSessionId ? s.streamingMessageIds.get(currentSessionId) ?? null : null),
|
|
[currentSessionId],
|
|
),
|
|
);
|
|
const activeStreamingPhase = useStreamingStore(
|
|
React.useCallback(
|
|
(s) => {
|
|
if (!streamingMessageId) return null;
|
|
return s.messageStreamStates.get(streamingMessageId)?.phase ?? null;
|
|
},
|
|
[streamingMessageId],
|
|
),
|
|
);
|
|
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
|
|
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
|
|
// Messages from sync system. Keep this gated by `messagesEnabled`, not
|
|
// `active`, so embedded panels can show history while the composer stays
|
|
// inactive until the parent confirms visibility.
|
|
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
|
enabled: messagesEnabled,
|
|
suspendPartUpdates: Boolean(streamingMessageId),
|
|
suspendPartUpdatesForMessageId: streamingMessageId,
|
|
});
|
|
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
|
const sessionMessageLoadState = useSessionMessageLoadState(
|
|
currentSessionId ?? '',
|
|
effectiveSessionDirectory,
|
|
);
|
|
const [firstVisiblePerformance] = React.useState(createFirstVisibleSessionPerformanceTracker);
|
|
|
|
React.useEffect(() => {
|
|
if (!active || !currentSessionKey || !hasRenderableSessionSnapshot || sessionMessages.length === 0) return;
|
|
return firstVisiblePerformance.schedule(currentSessionKey, sessionMessages.length);
|
|
}, [active, currentSessionKey, firstVisiblePerformance, hasRenderableSessionSnapshot, sessionMessages.length]);
|
|
|
|
// Plan detection - watches messages for plan creation and signals store
|
|
usePlanDetection(currentSessionId ?? '', sessionMessages);
|
|
|
|
// Session status from sync system
|
|
const sessionStatusForCurrent = useSessionStatus(currentSessionId ?? '', effectiveSessionDirectory) ?? IDLE_SESSION_STATUS;
|
|
|
|
// Scoped blocking requests — only subscribe to permissions/questions for
|
|
// the current session + descendant subagent sessions, not all sessions in
|
|
// the directory.
|
|
const sessionPermissions = useScopedBlockingPermissions(currentSessionId, effectiveSessionDirectory);
|
|
const sessionQuestions = useScopedBlockingQuestions(currentSessionId, effectiveSessionDirectory);
|
|
|
|
const hasUnreconciledQuestionTool = React.useMemo(
|
|
() => !sessionQuestions.some((question) => question.sessionID === currentSessionId)
|
|
&& hasActiveQuestionToolInCurrentTurn(sessionMessages),
|
|
[currentSessionId, sessionMessages, sessionQuestions],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!active || !currentSessionId || !effectiveSessionDirectory || !hasUnreconciledQuestionTool) return;
|
|
let cancelled = false;
|
|
|
|
void recoverPendingQuestionWithRetry(
|
|
() => sync.recoverPendingQuestions(currentSessionId, effectiveSessionDirectory),
|
|
{ isCancelled: () => cancelled },
|
|
);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [active, currentSessionId, effectiveSessionDirectory, hasUnreconciledQuestionTool, sync]);
|
|
|
|
const sessionIsWorking = React.useMemo(() => {
|
|
if (!currentSessionId || sessionPermissions.length > 0 || sessionQuestions.length > 0) {
|
|
return false;
|
|
}
|
|
|
|
const statusType = sessionStatusForCurrent.type ?? 'idle';
|
|
if (statusType === 'busy' || statusType === 'retry') {
|
|
return true;
|
|
}
|
|
|
|
const lastMessage = sessionMessages[sessionMessages.length - 1]?.info as Message | undefined;
|
|
return Boolean(
|
|
lastMessage
|
|
&& lastMessage.role === 'assistant'
|
|
&& typeof (lastMessage as { time?: { completed?: number } }).time?.completed !== 'number',
|
|
);
|
|
}, [currentSessionId, sessionMessages, sessionPermissions.length, sessionQuestions.length, sessionStatusForCurrent.type]);
|
|
const activeRetryStatus = React.useMemo(() => {
|
|
if (!currentSessionId || sessionStatusForCurrent.type !== 'retry') {
|
|
return null;
|
|
}
|
|
|
|
const rawMessage = typeof (sessionStatusForCurrent as { message?: string }).message === 'string'
|
|
? (((sessionStatusForCurrent as { message?: string }).message) ?? '').trim()
|
|
: '';
|
|
|
|
return {
|
|
sessionId: currentSessionId,
|
|
message: rawMessage || DEFAULT_RETRY_MESSAGE,
|
|
confirmedAt: (sessionStatusForCurrent as { confirmedAt?: number }).confirmedAt,
|
|
};
|
|
}, [currentSessionId, sessionStatusForCurrent]);
|
|
const [retryFallbackTimestamp, setRetryFallbackTimestamp] = React.useState<number>(0);
|
|
const retryFallbackSessionRef = React.useRef<string | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
if (!activeRetryStatus || typeof activeRetryStatus.confirmedAt === 'number') {
|
|
retryFallbackSessionRef.current = null;
|
|
setRetryFallbackTimestamp(0);
|
|
return;
|
|
}
|
|
|
|
if (retryFallbackSessionRef.current !== activeRetryStatus.sessionId) {
|
|
retryFallbackSessionRef.current = activeRetryStatus.sessionId;
|
|
setRetryFallbackTimestamp(Date.now());
|
|
}
|
|
}, [activeRetryStatus]);
|
|
|
|
const retryOverlay = React.useMemo(() => {
|
|
if (!activeRetryStatus) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...activeRetryStatus,
|
|
fallbackTimestamp: retryFallbackTimestamp,
|
|
};
|
|
}, [activeRetryStatus, retryFallbackTimestamp]);
|
|
|
|
// History metadata — use sync's hasMore/isLoading
|
|
const historyMeta = React.useMemo(() => {
|
|
if (!currentSessionId) return null;
|
|
return {
|
|
limit: sessionMessages.length,
|
|
complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor,
|
|
loading: sessionMessageLoadState.status === 'loading',
|
|
};
|
|
}, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]);
|
|
|
|
const { isMobile } = useDeviceInfo();
|
|
const isVSCode = isVSCodeRuntime();
|
|
const chatSurfaceMode = useChatSurfaceMode();
|
|
const draftOpen = Boolean(newSessionDraft?.open);
|
|
const isManagedChatContext = draftOpen
|
|
? newSessionDraft?.target === 'chat'
|
|
: isChatDirectoryPath(effectiveSessionDirectory);
|
|
// A draft can target another project or a pending worktree before it has a
|
|
// session. Keep the panel on that same directory so its project, MCP, and
|
|
// usage readouts describe where the draft will run rather than the project
|
|
// the user came from.
|
|
const workStatusDirectory = draftOpen
|
|
? (isManagedChatContext ? null : newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory)
|
|
: effectiveSessionDirectory;
|
|
const initError = useGlobalSyncStore((s) => s.error);
|
|
// Despite the historical name, this now covers mobile too: the mobile
|
|
// composer enters the same fullscreen-input mode via its drag handle.
|
|
const isDesktopExpandedInput = isExpandedInput;
|
|
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
|
|
// Work-status panel: a borderless column to the right of the transcript.
|
|
// It yields to the context panel and to a narrow chat; `rowRef` goes on the
|
|
// row that holds both columns, so its width never depends on the panel's
|
|
// own visibility.
|
|
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
|
|
isMobile,
|
|
isVSCode,
|
|
});
|
|
// Surfaces that never host the panel skip it entirely; the rest keep it
|
|
// mounted so its visibility can animate rather than snap.
|
|
const workStatusPanelMountable = !isMobile
|
|
&& !isVSCode
|
|
&& chatSurfaceMode !== 'mini-chat'
|
|
&& !isDesktopExpandedInput;
|
|
const showWorkStatusPanel = workStatusPanelMountable && workStatusVisible;
|
|
|
|
// Offered over the chat when there is no room beside it. The panel is still
|
|
// switched on; only the layout refuses it.
|
|
const workStatusPanelEnabled = useUIStore((state) => state.workStatusPanelEnabled);
|
|
const workStatusOverlayOpen = useUIStore((state) => state.workStatusOverlayOpen);
|
|
const setWorkStatusPanelFits = useUIStore((state) => state.setWorkStatusPanelFits);
|
|
// Mounted whenever it could be shown, not only while it is: an element
|
|
// that appears and disappears with the condition has nothing to animate.
|
|
const workStatusOverlayMountable = workStatusPanelMountable
|
|
&& workStatusPanelEnabled
|
|
&& !workStatusFits;
|
|
const showWorkStatusOverlay = workStatusOverlayMountable && workStatusOverlayOpen;
|
|
|
|
React.useEffect(() => {
|
|
setWorkStatusPanelFits(workStatusPanelMountable && workStatusFits);
|
|
return () => setWorkStatusPanelFits(false);
|
|
}, [setWorkStatusPanelFits, workStatusFits, workStatusPanelMountable]);
|
|
|
|
// Published so the header can drop the readouts the panel already carries.
|
|
// Cleared on unmount: a chat that goes away is not showing anything.
|
|
const setWorkStatusPanelVisible = useUIStore((state) => state.setWorkStatusPanelVisible);
|
|
React.useEffect(() => {
|
|
setWorkStatusPanelVisible(showWorkStatusPanel);
|
|
return () => setWorkStatusPanelVisible(false);
|
|
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
|
|
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
|
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
|
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
|
|
|
// In the embedded session-chat iframe, hide "Return to parent" when
|
|
// viewing the panel's anchor session (the one recorded in the URL). Going
|
|
// up from the anchor would show the primary session that's already in the
|
|
// main chat. Drilling into a deeper subtask (currentSessionId ≠ anchor)
|
|
// re-enables the button to navigate back to the embedded session.
|
|
const embeddedPanelAnchorSessionId = getEmbeddedSessionChatOriginSessionId();
|
|
const hideReturnToParent =
|
|
embeddedPanelAnchorSessionId !== null && currentSessionId === embeddedPanelAnchorSessionId;
|
|
|
|
const handleReturnToParentSession = React.useCallback(() => {
|
|
if (!parentSession) return;
|
|
const parentDirectory = (parentSession as Session & { directory?: string | null }).directory ?? null;
|
|
setCurrentSession(parentSession.id, parentDirectory);
|
|
}, [parentSession, setCurrentSession]);
|
|
|
|
const returnToParentButton = parentSession && !hideReturnToParent ? (
|
|
<Button
|
|
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={t('chat.container.returnToParent.aria')}
|
|
title={parentSession.title?.trim()
|
|
? t('chat.container.returnToParent.titleNamed', { title: parentSession.title })
|
|
: t('chat.container.returnToParent.title')}
|
|
>
|
|
<Icon name="arrow-left" className="h-4 w-4" />
|
|
{t('chat.container.returnToParent.label')}
|
|
</Button>
|
|
) : null;
|
|
const promptReadOnly = resolveChatPromptReadOnly(
|
|
currentSession,
|
|
embeddedAllowPrompting ?? allowPromptingSubagentSessions,
|
|
readOnly,
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
|
|
// The old `window.parent === window` check does not catch that, so
|
|
// `window.parent.postMessage(...)` threw on chat open:
|
|
// TypeError: Cannot read properties of undefined (reading 'postMessage')
|
|
if (typeof window === 'undefined' || !window.parent || window.parent === window) {
|
|
return;
|
|
}
|
|
|
|
const parentWindow = window.parent;
|
|
const applySetting = (value: boolean) => {
|
|
setEmbeddedAllowPrompting(value);
|
|
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
|
};
|
|
const scopedWindow = window as typeof window & {
|
|
__openchamberApplyChatSettingsSync?: (payload: { allowPromptingSubagentSessions: boolean }) => void;
|
|
};
|
|
const applySync = (payload: { allowPromptingSubagentSessions: boolean }) => {
|
|
applySetting(payload.allowPromptingSubagentSessions);
|
|
};
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.source !== parentWindow || event.origin !== window.location.origin) return;
|
|
const data = event.data as { type?: unknown; payload?: { allowPromptingSubagentSessions?: unknown } };
|
|
if (data?.type !== 'openchamber:chat-settings-sync'
|
|
|| typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return;
|
|
applySetting(data.payload.allowPromptingSubagentSessions);
|
|
};
|
|
|
|
scopedWindow.__openchamberApplyChatSettingsSync = applySync;
|
|
window.addEventListener('message', handleMessage);
|
|
parentWindow.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin);
|
|
return () => {
|
|
window.removeEventListener('message', handleMessage);
|
|
if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) {
|
|
delete scopedWindow.__openchamberApplyChatSettingsSync;
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (autoOpenDraft && !currentSessionId && !draftOpen) {
|
|
// Programmatic fallback, not user navigation — must not clear the
|
|
// persisted last-session pointer the cold-launch restore reads.
|
|
openNewSessionDraft({ automatic: true });
|
|
}
|
|
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
|
|
|
const activeTurnChangeRef = React.useRef<(turnId: string | null) => void>(() => {});
|
|
const handleActiveTurnChange = React.useCallback((turnId: string | null) => {
|
|
activeTurnChangeRef.current(turnId);
|
|
}, []);
|
|
|
|
// The composer sits below the timeline rather than over it, so no part of
|
|
// the scroll container is occluded. Mobile surfaces that float the composer
|
|
// pass their measured height here instead.
|
|
const composerOverlayHeight = 0;
|
|
const lastUserMessageId = React.useMemo(() => {
|
|
for (let index = sessionMessages.length - 1; index >= 0; index -= 1) {
|
|
const message = sessionMessages[index];
|
|
if (message.info.role === 'user') {
|
|
return message.info.id;
|
|
}
|
|
}
|
|
return null;
|
|
}, [sessionMessages]);
|
|
|
|
const {
|
|
scrollRef,
|
|
scrollNode,
|
|
registerList,
|
|
anchorMessageId,
|
|
onAnchorReady,
|
|
onAnchorSizeChanged,
|
|
onIsAtEndChange,
|
|
onManualNavigation,
|
|
onTimelineDataChange,
|
|
notifyContentChange: handleMessageContentChange,
|
|
getAnimationHandlers,
|
|
goToBottom,
|
|
scrollToBottomOnSend,
|
|
restoreSnapshot,
|
|
isPinned,
|
|
isFollowingProgrammatically,
|
|
showScrollButton,
|
|
} = useChatTimelineScroll({
|
|
currentSessionId,
|
|
currentSessionKey,
|
|
sessionMessageCount,
|
|
composerOverlayHeight,
|
|
lastUserMessageId,
|
|
onActiveTurnChange: handleActiveTurnChange,
|
|
});
|
|
|
|
const viewportMessages = sessionMessages;
|
|
|
|
const timelineController = useChatTimelineController({
|
|
sessionId: currentSessionId,
|
|
sessionKey: currentSessionKey,
|
|
messages: viewportMessages,
|
|
historyMeta,
|
|
scrollRef,
|
|
messageListRef,
|
|
loadMoreMessages,
|
|
goToBottom,
|
|
releaseAutoFollow: onManualNavigation,
|
|
isPinned,
|
|
showScrollButton,
|
|
});
|
|
// The list owns the scroll element, so the shadows and the load-older
|
|
// trigger bind to its node rather than to a wrapper we render.
|
|
const scrollNodeRef = React.useMemo(() => ({ current: scrollNode }), [scrollNode]);
|
|
useScrollShadow(scrollNodeRef, {
|
|
observeMutations: false,
|
|
hideTopShadow: isMobile && stickyUserHeader,
|
|
});
|
|
|
|
const handleHistoryScroll = timelineController.handleHistoryScroll;
|
|
React.useEffect(() => {
|
|
if (!scrollNode) return;
|
|
const onScroll = () => handleHistoryScroll();
|
|
scrollNode.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => {
|
|
scrollNode.removeEventListener('scroll', onScroll);
|
|
};
|
|
}, [handleHistoryScroll, scrollNode]);
|
|
|
|
const resumeToLatestInstant = React.useCallback(() => {
|
|
goToBottom('instant');
|
|
}, [goToBottom]);
|
|
// Mobile loads older history via an explicit top button instead of a
|
|
// scroll-position trigger (see handleHistoryScroll in the controller).
|
|
const showLoadOlderButton = isMobileSurfaceRuntime()
|
|
&& timelineController.historySignals.canLoadEarlier;
|
|
const timelineLoadEarlier = timelineController.loadEarlier;
|
|
const handleLoadOlderClick = React.useCallback(() => {
|
|
void timelineLoadEarlier({ userInitiated: true });
|
|
}, [timelineLoadEarlier]);
|
|
|
|
React.useEffect(() => {
|
|
activeTurnChangeRef.current = timelineController.handleActiveTurnChange;
|
|
}, [timelineController.handleActiveTurnChange]);
|
|
|
|
React.useEffect(() => {
|
|
if (sessionPermissions.length === 0 && sessionQuestions.length === 0) {
|
|
return;
|
|
}
|
|
handleMessageContentChange('permission');
|
|
}, [handleMessageContentChange, sessionPermissions, sessionQuestions]);
|
|
|
|
const navigation = useChatTurnNavigation({
|
|
sessionId: currentSessionId,
|
|
turnIds: timelineController.turnIds,
|
|
activeTurnId: timelineController.activeTurnId,
|
|
scrollToTurn: timelineController.scrollToTurn,
|
|
scrollToMessage: timelineController.scrollToMessage,
|
|
resumeToBottom: timelineController.resumeToBottomInstant,
|
|
});
|
|
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
|
|
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
|
|
}, [navigation]);
|
|
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
|
|
const showPromptNavigator = !isMobile
|
|
&& !isVSCode
|
|
&& !isDesktopExpandedInput
|
|
&& promptNavigatorEnabled
|
|
&& timelineController.turnIds.length >= 2;
|
|
|
|
React.useEffect(() => {
|
|
if (!showPromptNavigator) {
|
|
useUIStore.getState().setPromptNavigatorPanelOpen(false);
|
|
}
|
|
}, [showPromptNavigator]);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined' || !currentSessionId) return;
|
|
|
|
const handleForceScrollBottom = (event: Event) => {
|
|
const customEvent = event as CustomEvent<{ sessionId?: string }>;
|
|
if (customEvent.detail?.sessionId && customEvent.detail.sessionId !== currentSessionId) return;
|
|
goToBottom('instant');
|
|
};
|
|
|
|
window.addEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
|
|
return () => {
|
|
window.removeEventListener(CHAT_FORCE_SCROLL_BOTTOM_EVENT, handleForceScrollBottom as EventListener);
|
|
};
|
|
}, [currentSessionId, goToBottom]);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined' || !currentSessionId || isDesktopExpandedInput) {
|
|
return;
|
|
}
|
|
|
|
const handleChatTurnKeyDown = (event: KeyboardEvent) => {
|
|
if (event.defaultPrevented || event.isComposing) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
|
|
return;
|
|
}
|
|
|
|
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
if (hasBlockingChatOverlay()) {
|
|
return;
|
|
}
|
|
|
|
const scrollContainer = scrollRef.current;
|
|
if (shouldIgnoreChatNavigationForFocus(document.activeElement, scrollContainer)) {
|
|
return;
|
|
}
|
|
|
|
if (shouldIgnoreChatNavigationTarget(event.target)) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
const offset = event.key === 'ArrowUp' ? -1 : 1;
|
|
void navigation.scrollByTurnOffset(offset, { resumePastEnd: false });
|
|
};
|
|
|
|
window.addEventListener('keydown', handleChatTurnKeyDown);
|
|
return () => {
|
|
window.removeEventListener('keydown', handleChatTurnKeyDown);
|
|
};
|
|
}, [currentSessionId, isDesktopExpandedInput, navigation, scrollRef]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const container = scrollRef.current;
|
|
if (!container) return;
|
|
|
|
const updateChatScrollHeight = () => {
|
|
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
|
|
};
|
|
|
|
updateChatScrollHeight();
|
|
|
|
let rafId = 0;
|
|
const scheduleUpdate = () => {
|
|
if (rafId) return;
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = 0;
|
|
updateChatScrollHeight();
|
|
});
|
|
};
|
|
|
|
if (typeof ResizeObserver === 'undefined') {
|
|
window.addEventListener('resize', scheduleUpdate);
|
|
return () => {
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
window.removeEventListener('resize', scheduleUpdate);
|
|
};
|
|
}
|
|
|
|
const resizeObserver = new ResizeObserver(scheduleUpdate);
|
|
resizeObserver.observe(container);
|
|
|
|
return () => {
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
|
|
|
const lastScrolledSessionKeyRef = React.useRef<string | null>(null);
|
|
|
|
const isSessionHydrating =
|
|
Boolean(currentSessionId)
|
|
&& !hasRenderableSessionSnapshot;
|
|
const retrySessionLoad = React.useCallback(() => {
|
|
if (!messagesEnabled || !currentSessionId) return;
|
|
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
|
}, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]);
|
|
|
|
React.useEffect(() => {
|
|
if (!active || !currentSessionId) return;
|
|
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
|
|
|
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
|
lastScrolledSessionKeyRef.current = currentSessionKey;
|
|
if (hasHashTarget) {
|
|
// Hash navigation handler will scroll to target; we just release auto-follow.
|
|
onManualNavigation();
|
|
return;
|
|
}
|
|
|
|
const run = () => {
|
|
void restoreSnapshot();
|
|
};
|
|
if (typeof window === 'undefined') {
|
|
run();
|
|
} else {
|
|
window.requestAnimationFrame(run);
|
|
}
|
|
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
|
|
|
|
React.useEffect(() => {
|
|
if (!messagesEnabled || !currentSessionId) return;
|
|
if (hasRenderableSessionSnapshot) return;
|
|
void ensureSessionRenderable(currentSessionId);
|
|
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
|
|
|
|
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
|
|
const previousComposerRectRef = React.useRef<DOMRect | null>(null);
|
|
const previousDraftOpenRef = React.useRef(draftOpen);
|
|
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
|
|
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
|
|
const shouldAnimateDraftTransition = Boolean(
|
|
currentSessionId && materializedDraftSessionId === currentSessionId,
|
|
);
|
|
const draftPresentationExiting = draftExitAnimating
|
|
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
|
|
const draftLayoutVisible = draftOpen || draftPresentationExiting;
|
|
|
|
React.useLayoutEffect(() => {
|
|
if (draftOpen) {
|
|
setDraftExitAnimating(false);
|
|
return;
|
|
}
|
|
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
|
|
|
|
setDraftExitAnimating(true);
|
|
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [draftOpen, shouldAnimateDraftTransition]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
previousDraftOpenRef.current = draftOpen;
|
|
}, [draftOpen]);
|
|
|
|
React.useLayoutEffect(() => {
|
|
const composerSlot = composerSlotRef.current;
|
|
if (!composerSlot) return;
|
|
|
|
const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]');
|
|
const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect();
|
|
const previousRect = previousComposerRectRef.current;
|
|
const leftDraftLayout = previousDraftLayoutVisibleRef.current
|
|
&& !draftLayoutVisible
|
|
&& Boolean(currentSessionId);
|
|
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
|
|
const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
|
|
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
|
|
const deltaX = previousRect.left - currentRect.left;
|
|
const deltaY = previousRect.top - currentRect.top;
|
|
composerSlot.animate(
|
|
[
|
|
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
|
|
{ transform: 'translate(0, 0)' },
|
|
],
|
|
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
|
|
);
|
|
}
|
|
previousComposerRectRef.current = currentRect;
|
|
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
|
|
if (leftDraftLayout && currentSessionId) {
|
|
clearMaterializedDraftSession(currentSessionId);
|
|
}
|
|
}, [
|
|
clearMaterializedDraftSession,
|
|
currentSessionId,
|
|
draftLayoutVisible,
|
|
isDesktopExpandedInput,
|
|
shouldAnimateDraftTransition,
|
|
useCompactDraftLayout,
|
|
]);
|
|
|
|
if (!currentSessionId && !draftOpen) {
|
|
// The auto-open effect runs on the next tick. Use a neutral background
|
|
// until then instead of flashing the standard empty state.
|
|
if (autoOpenDraft && !initError) {
|
|
return <div className="flex h-full flex-col bg-background" />;
|
|
}
|
|
return (
|
|
<div className="flex flex-col h-full bg-background">
|
|
<ChatEmptyState />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const sessionSurface = (() => {
|
|
if (draftOpen || draftPresentationExiting) {
|
|
if (!useCompactDraftLayout || isDesktopExpandedInput) {
|
|
return null;
|
|
}
|
|
return <DraftWelcome exiting={draftPresentationExiting} />;
|
|
}
|
|
|
|
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
|
if (sessionMessageLoadState.status === 'error') {
|
|
return (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
|
<div className="max-w-sm text-center">
|
|
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
|
<Icon name="error-warning" className="size-4" />
|
|
</div>
|
|
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
|
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
|
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
|
{t('chat.container.sessionLoadError.retry')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'relative min-h-0',
|
|
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
|
)}
|
|
aria-hidden={isDesktopExpandedInput}
|
|
>
|
|
<div className="absolute inset-0 overflow-y-auto overflow-x-hidden bg-background pt-6" style={CHAT_SCROLL_STYLE}>
|
|
<div className="space-y-4">
|
|
{HYDRATING_SKELETON_ITEMS.map((item) => (
|
|
<div key={item.id} className="group w-full">
|
|
<div className="chat-message-column">
|
|
<div className="space-y-2.5 px-4 py-3">
|
|
<div className="space-y-1.5">
|
|
{item.toolRows.map((row) => (
|
|
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
|
|
<Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
|
|
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
|
|
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="space-y-1.5 pt-1">
|
|
{item.textWidths.map((width, index) => (
|
|
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'relative min-h-0',
|
|
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
|
)}
|
|
aria-hidden={isDesktopExpandedInput}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ChatViewport
|
|
currentSessionId={currentSessionId ?? ''}
|
|
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
|
isMobile={isMobile}
|
|
directory={effectiveSessionDirectory}
|
|
scrollRef={scrollRef}
|
|
registerList={registerList}
|
|
anchorMessageId={anchorMessageId}
|
|
onAnchorReady={onAnchorReady}
|
|
onAnchorSizeChanged={onAnchorSizeChanged}
|
|
composerOverlayHeight={composerOverlayHeight}
|
|
onIsAtEndChange={onIsAtEndChange}
|
|
onTimelineDataChange={onTimelineDataChange}
|
|
messageListRef={messageListRef}
|
|
pendingRevealWork={timelineController.pendingRevealWork}
|
|
renderedMessages={timelineController.renderedMessages}
|
|
isLoadingOlder={timelineController.isLoadingOlder}
|
|
sessionIsWorking={sessionIsWorking}
|
|
streamingMessageId={streamingMessageId}
|
|
activeStreamingPhase={activeStreamingPhase}
|
|
retryOverlay={retryOverlay}
|
|
handleMessageContentChange={handleMessageContentChange}
|
|
getAnimationHandlers={getAnimationHandlers}
|
|
scrollToBottom={resumeToLatestInstant}
|
|
sessionQuestions={sessionQuestions}
|
|
sessionPermissions={sessionPermissions}
|
|
isProgrammaticFollowActive={isFollowingProgrammatically}
|
|
showLoadOlderButton={showLoadOlderButton}
|
|
onLoadOlder={handleLoadOlderClick}
|
|
turnIds={timelineController.turnIds}
|
|
activeTurnId={timelineController.activeTurnId}
|
|
onSelectTurn={handlePromptNavigatorSelect}
|
|
showPromptNavigator={showPromptNavigator}
|
|
canLoadEarlierPrompts={canLoadEarlierPrompts}
|
|
isLoadingOlderPrompts={timelineController.isLoadingOlder}
|
|
onLoadEarlierPrompts={handleLoadOlderClick}
|
|
/>
|
|
);
|
|
})();
|
|
|
|
return (
|
|
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
|
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
|
|
{returnToParentButton}
|
|
{sessionSurface}
|
|
|
|
<div
|
|
ref={composerSlotRef}
|
|
className={cn(
|
|
'relative z-10 flex min-h-0',
|
|
isDesktopExpandedInput
|
|
? 'flex-1 min-h-0 bg-background'
|
|
: draftLayoutVisible && !useCompactDraftLayout
|
|
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
|
|
: 'bg-background'
|
|
)}
|
|
>
|
|
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
|
|
<ScrollToBottomButton
|
|
visible={timelineController.showScrollToBottom}
|
|
onClick={navigation.resumeToLatest}
|
|
/>
|
|
)}
|
|
{promptReadOnly ? (
|
|
<ReadOnlyPromptBanner />
|
|
) : (
|
|
<ChatInput
|
|
active={active}
|
|
scrollToBottom={scrollToBottomOnSend}
|
|
draftPresentationExiting={draftPresentationExiting}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Inside the chat column, not beside it: as a row sibling it took
|
|
part in the flex layout and pushed the transcript, which is the
|
|
one thing an overlay must not do. */}
|
|
{workStatusOverlayMountable ? (
|
|
<WorkStatusPanel
|
|
overlay
|
|
visible={showWorkStatusOverlay}
|
|
sessionId={currentSessionId ?? null}
|
|
directory={workStatusDirectory ?? null}
|
|
repositoryEnabled={!isManagedChatContext}
|
|
/>
|
|
) : null}
|
|
|
|
<TimelineDialog
|
|
open={isTimelineDialogOpen}
|
|
onOpenChange={setTimelineDialogOpen}
|
|
onScrollToMessage={timelineController.scrollToMessage}
|
|
onScrollByTurnOffset={navigation.scrollByTurnOffset}
|
|
onResumeToLatest={resumeToLatestInstant}
|
|
canLoadEarlier={timelineController.historySignals.canLoadEarlier}
|
|
isLoadingEarlier={timelineController.isLoadingOlder}
|
|
onLoadEarlier={handleLoadOlderClick}
|
|
/>
|
|
</div>
|
|
{/* Kept mounted while it could ever show, so it can animate its own
|
|
collapse; `visible` drives that. Unmounting on the spot is what made
|
|
the chat jump wide before easing narrow again. */}
|
|
{workStatusPanelMountable ? (
|
|
<WorkStatusPanel
|
|
visible={showWorkStatusPanel}
|
|
sessionId={currentSessionId ?? null}
|
|
directory={workStatusDirectory ?? null}
|
|
repositoryEnabled={!isManagedChatContext}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|