Files
openchamber/packages/ui/src/components/chat/ChatContainer.tsx
T
Bohdan Triapitsyn 79143bff4c feat: massive chat reliability + UX pass (web/desktop/mobile/vscode) (#593)
## Added Features
- Add VS Code save-as-image flow for assistant messages via webview bridge + native save dialog.
- Add hourly desktop update checks after startup.
- Add new tool output display mode: `Changes` (auto-expand edit/write/patch only; keep activity expanded; mode guidance text).
- Add GitHub PR attachment flow in chat input with PR picker + attached PR chip/details.
- Add mobile overlay presentation for GitHub Issue and PR pickers (shared with desktop picker content).

## Fixes
- Save-gate project icon updates until explicit Save; allow icon removal with same save-gated behavior.
- Restore clickable chat action buttons in sticky header mode (desktop + Firefox hit-target issue).
- Clamp sticky user messages to bounded chat height and allow internal scrolling.
- Prevent drawer context crash during iPad/tablet orientation switching.
- Improve text-selection action menu placement on narrow screens.
- Move assistant message time into clock tooltip; keep duration display clean.
- Hide `Link GitHub Issue` row in VS Code chat input area (GitHub flow is not yet ready there).
- Remove laggy close animation in text-selection popover; keep open motion/positioning behavior.
- Fetch branches when picker opens and cache empty; show loading state instead of false “No branches found”.
- Fix share-image export metadata rendering (theme background resolution, timestamp rendering, footer alignment).
- Scope MCP services status/toggles to active directory to avoid cross-project leakage.
- Improve long user-message clamp behavior (40% cap variant, hidden scrollbar, scroll shadows, expansion detection).
- Fix desktop `Check for Updates` menu handler; prevent duplicate checks; show clear success/error toasts.
- Stabilize long user-message scrolling behavior (follow-up hardening).
- Avoid premature web update failure on slower servers.
- Restore user message image previews + fullscreen gallery navigation payload.
- Repair desktop chat drag-and-drop image attachments when native drop coords are missing.
- Move GitHub issue linking entry into Add attachment menu.
- Align header context usage percentage visuals with context panel.
- Align `@` file search with active project in all runtimes.
- Route `@` file discovery through OpenCode SDK `find.files`; remove legacy `/api/fs/search` reliance.
- Make chat `@` mention behavior consistent with files-style behavior.
- Keep status-row todos in stable order after status changes; add compact status icons; replace noisy priority labels.

## Refactors / UX Consistency
- Simplify chat attachment model and remove project file picker path.
- Keep composer focused on `@` mention file flow.
- Use direct `Attach files` action in VS Code instead of attachment dropdown path.
- Unify issue/PR picker behavior between desktop and mobile overlays.
2026-03-04 01:41:01 +02:00

687 lines
25 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.useLayoutEffect(() => {
const container = scrollRef.current;
if (!container) {
return;
}
const updateChatScrollHeight = () => {
container.style.setProperty('--chat-scroll-height', `${container.clientHeight}px`);
};
updateChatScrollHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateChatScrollHeight);
return () => {
window.removeEventListener('resize', updateChatScrollHeight);
};
}
const resizeObserver = new ResizeObserver(updateChatScrollHeight);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
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>
);
};