Merge upstream main into feat/subagent-cost-rollup

This commit is contained in:
igorvelho
2026-08-25 23:09:44 +01:00
156 changed files with 5084 additions and 4690 deletions
+1
View File
@@ -43,6 +43,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@legendapp/list": "3.3.8",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
-2
View File
@@ -626,7 +626,6 @@ function App({ apis }: AppProps) {
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim()
: null;
useUIStore.getState().setActiveMainTab('chat');
void useSessionUIStore.getState().setCurrentSession(sessionId, directory);
};
@@ -675,7 +674,6 @@ function App({ apis }: AppProps) {
? detail.projectId.trim()
: null;
const hasProjectTarget = Boolean(directory || projectId);
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({
target: hasProjectTarget ? 'project' : 'chat',
+5 -8
View File
@@ -43,6 +43,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getProjectLabel, normalizePath } from './mobilePaths';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { cn } from '@/lib/utils';
import {
@@ -188,11 +189,8 @@ const findExactProjectMatch = (projects: ProjectMeta[], directory: string): Proj
return projects.find((project) => projectMatchesExactDirectory(project, normalizedDirectory)) ?? null;
};
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean => {
if (!query) return true;
const haystack = `${session.title ?? ''} ${session.id} ${getSessionDirectory(session)} ${projectLabel}`.toLowerCase();
return haystack.includes(query);
};
const sessionMatchesQuery = (session: Session, projectLabel: string, query: string): boolean =>
matchesRankQuery([session.title, session.id, getSessionDirectory(session), projectLabel], query);
const MobileProjectIcon: React.FC<{
project: Pick<ProjectMeta, 'id' | 'icon' | 'color' | 'iconImage' | 'iconBackground'>;
@@ -1355,7 +1353,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const filteredNodes = React.useMemo(() => {
if (!normalizedQuery) return projectNodes;
return projectNodes.filter((node) => {
if (`${node.project.label} ${node.project.path}`.toLowerCase().includes(normalizedQuery)) return true;
if (matchesRankQuery([node.project.label, node.project.path], normalizedQuery)) return true;
return node.buckets.some((bucket) =>
bucket.sessions.some((session) => sessionMatchesQuery(session, node.project.label, normalizedQuery)),
);
@@ -1385,8 +1383,7 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ open,
const searchProjectMatches = React.useMemo(() => {
if (!normalizedQuery) return [] as Array<ProjectMeta & { sessionCount: number }>;
return projectsMeta
.filter((project) => `${project.label} ${project.path}`.toLowerCase().includes(normalizedQuery))
return rankByQuery(projectsMeta, normalizedQuery, (project) => [project.label, project.path])
.map((project) => ({
...project,
sessionCount: sessions.filter((session) => {
@@ -6,7 +6,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useProjectContextStore } from '@/stores/useProjectContextStore';
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { useUIStore } from '@/stores/useUIStore';
import { usePermissionStore } from '@/stores/permissionStore';
import { useFileSearchStore } from '@/stores/useFileSearchStore';
import { useGitStore } from '@/stores/useGitStore';
@@ -37,7 +36,6 @@ export const reconnectAppForTransportSwitch = (): void => {
export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedDetail): void => {
useSessionUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
useUIStore.getState().prepareForRuntimeSwitch(detail.previousRuntimeKey);
if (detail.previousRuntimeKey) {
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
}
@@ -71,7 +69,6 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
resetStreamingState();
queueMicrotask(() => void syncDesktopSettings());
};
+242 -113
View File
@@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer';
import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer';
import ScrollToBottomButton from './components/ScrollToBottomButton';
import { PromptNavigatorRail } from './components/PromptNavigatorRail';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useScrollShadow } from '@/components/ui/useScrollShadow';
import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll';
import { useChatTimelineController } from './hooks/useChatTimelineController';
import { TimelineDialog } from './TimelineDialog';
import { useChatTurnNavigation } from './hooks/useChatTurnNavigation';
@@ -151,11 +151,15 @@ type ChatViewportProps = {
currentSessionKey: string;
isDesktopExpandedInput: boolean;
isMobile: boolean;
stickyUserHeader: boolean;
directory?: string;
scrollRef: React.RefObject<HTMLDivElement | null>;
messageListRef: React.RefObject<MessageListHandle | null>;
pendingRevealWork: boolean;
registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void;
onTimelineDataChange: () => void;
renderedMessages: SessionMessageRecord[];
isLoadingOlder: boolean;
sessionIsWorking: boolean;
@@ -167,10 +171,11 @@ type ChatViewportProps = {
confirmedAt?: number;
fallbackTimestamp?: number;
} | null;
handleMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
handleHistoryScroll: () => void;
scrollToBottom: () => void;
endPinningReleased: boolean;
// One-shot fade for content that replaced the hydration skeleton;
// cached sessions render instantly without it.
revealContent: boolean;
sessionQuestions: QuestionRequest[];
sessionPermissions: PermissionRequest[];
isProgrammaticFollowActive: boolean;
@@ -190,21 +195,24 @@ const ChatViewport = React.memo(({
currentSessionKey,
isDesktopExpandedInput,
isMobile,
stickyUserHeader,
directory,
scrollRef,
messageListRef,
pendingRevealWork,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onTimelineDataChange,
renderedMessages,
isLoadingOlder,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
retryOverlay,
handleMessageContentChange,
getAnimationHandlers,
handleHistoryScroll,
scrollToBottom,
endPinningReleased,
revealContent,
sessionQuestions,
sessionPermissions,
isProgrammaticFollowActive,
@@ -315,82 +323,95 @@ const ChatViewport = React.memo(({
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="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'
: 'flex-1',
revealContent && !isDesktopExpandedInput && 'oc-chat-hydration-reveal',
)}
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}
style={CHAT_SCROLL_STYLE}
observeMutations={false}
hideTopShadow={isMobile && stickyUserHeader}
tabIndex={0}
onClick={focusScrollContainer}
onScroll={handleHistoryScroll}
data-scroll-shadow="true"
data-scrollbar="chat"
>
<div className="relative z-0 min-h-full">
{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>
)}
<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}
scrollRef={scrollRef}
directory={directory}
/>
{(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" />
</div>
</ScrollShadow>
<MessageList
key={currentSessionKey}
ref={messageListRef}
sessionKey={currentSessionId}
messages={renderedMessages}
sessionIsWorking={sessionIsWorking}
activeStreamingMessageId={streamingMessageId}
activeStreamingPhase={activeStreamingPhase}
retryOverlay={retryOverlay}
isLoadingOlder={isLoadingOlder}
scrollToBottom={scrollToBottom}
endPinningReleased={endPinningReleased}
directory={directory}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
// Zero end inset: the footer spacer already reserves the
// zone the floating status row covers; adding its height
// again produced a double-tall blank band at rest.
composerOverlayHeight={0}
onIsAtEndChange={onIsAtEndChange}
onTimelineDataChange={onTimelineDataChange}
listHeader={listHeader}
listFooter={listFooter}
scrollContainerProps={scrollContainerProps}
/>
<OverlayScrollbar containerRef={scrollRef} suppressVisibility={isProgrammaticFollowActive} userIntentOnly observeMutations={false} />
{showPromptNavigator && promptTurnIds.length >= 2 ? (
<PromptNavigatorRail
@@ -411,21 +432,18 @@ const ChatViewport = React.memo(({
&& prev.currentSessionKey === next.currentSessionKey
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
&& prev.isMobile === next.isMobile
&& prev.stickyUserHeader === next.stickyUserHeader
&& 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.handleHistoryScroll === next.handleHistoryScroll
&& prev.scrollToBottom === next.scrollToBottom
&& prev.endPinningReleased === next.endPinningReleased
&& prev.revealContent === next.revealContent
&& prev.sessionQuestions === next.sessionQuestions
&& prev.sessionPermissions === next.sessionPermissions
&& prev.isProgrammaticFollowActive === next.isProgrammaticFollowActive
@@ -799,6 +817,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return () => setWorkStatusPanelVisible(false);
}, [setWorkStatusPanelVisible, showWorkStatusPanel]);
const messageListRef = React.useRef<MessageListHandle | null>(null);
// Session keys that showed the hydration skeleton this app run; their
// content gets a one-shot reveal fade once it replaces the skeleton.
const hydrationRevealKeyRef = React.useRef<string | null>(null);
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
@@ -891,23 +913,67 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
activeTurnChangeRef.current(turnId);
}, []);
// The composer sits below the timeline, but the status/working row floats
// OVER the timeline's bottom edge; its measured height keeps the live
// streaming line above it and reserves matching end inset in the list.
const [statusOverlayHeight, setStatusOverlayHeight] = React.useState(0);
const composerOverlayHeight = statusOverlayHeight;
const statusOverlayObserverRef = React.useRef<ResizeObserver | null>(null);
const onStatusOverlayNode = React.useCallback((node: HTMLDivElement | null) => {
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
if (!node || !globalThis.ResizeObserver) {
setStatusOverlayHeight(0);
return;
}
const update = () => {
// +8 for the mb-2 gap between the row and the composer, which the
// node's own box does not include.
const height = node.getBoundingClientRect().height + 8;
setStatusOverlayHeight((prev) => (Math.abs(prev - height) < 1 ? prev : height));
};
const observer = new ResizeObserver(update);
observer.observe(node);
statusOverlayObserverRef.current = observer;
update();
}, []);
React.useEffect(() => () => {
statusOverlayObserverRef.current?.disconnect();
statusOverlayObserverRef.current = null;
}, []);
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,
notifyContentChange: handleMessageContentChange,
getAnimationHandlers,
scrollNode,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onManualNavigation,
onTimelineDataChange,
goToBottom,
scrollToBottomOnSend,
releaseAutoFollow,
restoreSnapshot,
isPinned,
isFollowingProgrammatically,
showScrollButton,
} = useChatAutoFollow({
userOwnsScroll,
} = useChatTimelineScroll({
currentSessionId,
currentSessionKey,
sessionMessageCount,
sessionIsWorking,
isMobile,
composerOverlayHeight,
lastUserMessageId,
onActiveTurnChange: handleActiveTurnChange,
});
@@ -922,33 +988,49 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
messageListRef,
loadMoreMessages,
goToBottom,
releaseAutoFollow,
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(() => {
// Loading older history is an explicit move INTO the past: release
// live follow first, or the prepend's content growth would trigger an
// end correction and throw the viewport to the bottom.
onManualNavigation();
void timelineLoadEarlier({ userInitiated: true });
}, [timelineLoadEarlier]);
}, [onManualNavigation, 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,
@@ -958,7 +1040,10 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
resumeToBottom: timelineController.resumeToBottomInstant,
});
const handlePromptNavigatorSelect = React.useCallback((turnId: string) => {
void navigation.scrollToTurnId(turnId, { behavior: 'smooth' });
// Instant on purpose: a long smooth scroll through a virtualized
// timeline gets cancelled by row remounts and lands mid-way or on the
// wrong message; a teleport always arrives.
void navigation.scrollToTurnId(turnId, { behavior: 'auto' });
}, [navigation]);
const canLoadEarlierPrompts = timelineController.historySignals.canLoadEarlier;
const showPromptNavigator = !isMobile
@@ -1006,8 +1091,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return;
}
const { activeMainTab } = useUIStore.getState();
if (activeMainTab !== 'chat' || hasBlockingChatOverlay()) {
if (hasBlockingChatOverlay()) {
return;
}
@@ -1072,6 +1156,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isSessionHydrating =
Boolean(currentSessionId)
&& !hasRenderableSessionSnapshot;
React.useEffect(() => {
if (isSessionHydrating || hydrationRevealKeyRef.current === null) return;
// One-shot: forget the key after the reveal animation has played so a
// later (now cached) visit to the same session opens instantly.
const timer = setTimeout(() => {
hydrationRevealKeyRef.current = null;
}, 400);
return () => clearTimeout(timer);
}, [isSessionHydrating, currentSessionKey]);
const retrySessionLoad = React.useCallback(() => {
if (!messagesEnabled || !currentSessionId) return;
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
@@ -1085,7 +1178,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
lastScrolledSessionKeyRef.current = currentSessionKey;
if (hasHashTarget) {
// Hash navigation handler will scroll to target; we just release auto-follow.
releaseAutoFollow();
onManualNavigation();
return;
}
@@ -1097,7 +1190,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
} else {
window.requestAnimationFrame(run);
}
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
}, [active, currentSessionId, currentSessionKey, onManualNavigation, restoreSnapshot]);
React.useEffect(() => {
if (!messagesEnabled || !currentSessionId) return;
@@ -1192,7 +1285,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return <DraftWelcome exiting={draftPresentationExiting} />;
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
const showHydrationSkeleton = isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking;
if (showHydrationSkeleton) {
hydrationRevealKeyRef.current = currentSessionKey ?? currentSessionId ?? null;
}
if (showHydrationSkeleton) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
@@ -1212,6 +1309,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
return (
<div
data-chat-hydration-skeleton=""
className={cn(
'relative min-h-0',
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
@@ -1266,21 +1364,24 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
directory={effectiveSessionDirectory}
scrollRef={scrollRef}
registerList={registerList}
anchorMessageId={anchorMessageId}
onAnchorReady={onAnchorReady}
onAnchorSizeChanged={onAnchorSizeChanged}
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}
handleHistoryScroll={timelineController.handleHistoryScroll}
scrollToBottom={resumeToLatestInstant}
endPinningReleased={userOwnsScroll}
revealContent={hydrationRevealKeyRef.current !== null && hydrationRevealKeyRef.current === (currentSessionKey ?? currentSessionId ?? null)}
sessionQuestions={sessionQuestions}
sessionPermissions={sessionPermissions}
isProgrammaticFollowActive={isFollowingProgrammatically}
@@ -1315,10 +1416,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
)}
>
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
<>
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
working={sessionIsWorking}
onClick={navigation.resumeToLatest}
/>
{/* Same anchor and column as the pill, so the status
row and the pill it hands off to share the exact
distance from the input and the same left edge. */}
<div
className={cn(
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
userOwnsScroll && 'opacity-0',
)}
>
<div className="chat-input-column">
{/* The glass chip itself is rendered inside
StatusRow (its root is a size container
that cannot shrink-wrap). */}
<div
ref={onStatusOverlayNode}
className={cn(
'[&:not(:has(*))]:hidden',
userOwnsScroll ? 'pointer-events-none' : 'pointer-events-auto',
)}
>
<StatusRowContainer />
</div>
</div>
</div>
</>
)}
{promptReadOnly ? (
<ReadOnlyPromptBanner />
@@ -1326,6 +1454,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
scrollToLatest={resumeToLatestInstant}
draftPresentationExiting={draftPresentationExiting}
/>
)}
+20 -13
View File
@@ -48,7 +48,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete';
import { cn } from "@/lib/utils";
import { ModelControls } from './ModelControls';
import { parseAgentMentions } from '@/lib/messages/agentMentions';
import { StatusRow } from './StatusRow';
import { ComposerStatusBar } from './ComposerStatusBar';
import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
@@ -220,11 +220,15 @@ const MemoModelControls = React.memo(ModelControls);
const MemoComposerDictation = React.memo(ComposerDictation);
const MemoMobileAgentButton = React.memo(MobileAgentButton);
const MemoMobileModelButton = React.memo(MobileModelButton);
const MemoStatusRow = React.memo(StatusRow);
const MemoComposerStatusBar = React.memo(ComposerStatusBar);
interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
// Queued sends do not create a user row (the queue delivers later), so
// the anchor-arming scrollToBottom is wrong for them; this returns the
// viewport to the live edge instead.
scrollToLatest?: () => void;
active?: boolean;
draftPresentationExiting?: boolean;
}
@@ -243,6 +247,7 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
scrollToLatest,
active = true,
draftPresentationExiting = false,
}) => {
@@ -898,6 +903,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
} : undefined,
});
// Sending while the agent works must still take the reader to the
// live edge — a queued message produces no user row yet, so the
// anchor path has nothing to claim and would leave the viewport
// parked mid-history.
scrollToLatest?.();
// Clear input and attachments
// Note: confirmedMentionsRef is NOT cleared here because queued messages
// are processed later in handleSubmit which reads the ref via extractInlineFileMentions.
@@ -910,7 +921,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
if (!isMobile) {
composerRef.current?.focus();
}
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant]);
}, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]);
const handleQueuedMessageEdit = React.useCallback((content: string) => {
setMessage(content);
@@ -1289,6 +1300,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
...additionalParts.flatMap(p => p.attachments ?? []),
];
// Arm the timeline anchor BEFORE the optimistic user row can commit;
// arming after (or a frame later) races the commit and the anchor
// never claims the new message.
scrollToBottom?.();
const sendPromise = sendMessage(
primaryText,
providerIdToSend,
@@ -1307,14 +1323,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
}
};
if (typeof window === 'undefined') {
scrollToBottom?.();
} else {
window.requestAnimationFrame(() => {
scrollToBottom?.();
});
}
void sendPromise.then(() => {
// Record what this session was pointed at, so the work-status panel
// can show it as a context source long after the message scrolled
@@ -2648,9 +2656,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({
sessionId={currentSessionId}
directory={currentSessionDirectoryForSync ?? currentDirectory}
/>
<MemoStatusRow
<MemoComposerStatusBar
showAbortStatus={showAbortStatus}
showAssistantStatus={false}
showTodos={composerStatusExtrasEnabled}
leftAccessory={!composerStatusExtrasEnabled || newSessionDraftOpen || !hasPendingChanges
? null
@@ -14,7 +14,6 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
import type { AgentMentionInfo } from './message/types';
import type { StreamPhase, ToolPopupContent } from './message/types';
@@ -132,8 +131,6 @@ interface ChatMessageProps {
info: Message;
parts: Part[];
};
onContentChange?: (reason?: ContentChangeReason) => void;
animationHandlers?: AnimationHandlers;
scrollToBottom?: () => void;
turnGroupingContext?: TurnGroupingContext;
assistantHeaderMessageId?: string;
@@ -148,8 +145,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
message,
previousMessage,
nextMessage,
onContentChange,
animationHandlers,
turnGroupingContext,
assistantHeaderMessageId,
isInActiveTurn = false,
@@ -850,35 +845,12 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
});
}, [defaultOpenToolIds, effectiveExpandedTools, message.info.id]);
const resolvedAnimationHandlers = animationHandlers ?? null;
const hasAnnouncedAuxiliaryScrollRef = React.useRef(false);
const animationCompletedRef = React.useRef(false);
const hasRequestedReservationRef = React.useRef(false);
const animationStartNotifiedRef = React.useRef(false);
const hasTriggeredReservationOnceRef = React.useRef(false);
const hasEverStreamedRef = React.useRef(false);
React.useEffect(() => {
animationCompletedRef.current = false;
hasRequestedReservationRef.current = false;
animationStartNotifiedRef.current = false;
hasTriggeredReservationOnceRef.current = false;
hasAnnouncedAuxiliaryScrollRef.current = false;
hasEverStreamedRef.current = false;
}, [message.info.id]);
const handleAuxiliaryContentComplete = React.useCallback(() => {
if (isUser) {
return;
}
if (hasAnnouncedAuxiliaryScrollRef.current) {
return;
}
hasAnnouncedAuxiliaryScrollRef.current = true;
onContentChange?.('structural');
}, [isUser, onContentChange]);
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
@@ -901,114 +873,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
hasEverStreamedRef.current = true;
}
const hasReasoningParts = React.useMemo(() => {
if (isUser) {
return false;
}
return visibleParts.some((part) => part.type === 'reasoning');
}, [isUser, visibleParts]);
const allowAnimation = shouldAnimateMessage && !isAnimationSettled && !isStreamingPhase && !hasEverStreamedRef.current;
const shouldReserveAnimationSpace = !isUser && shouldAnimateMessage && assistantTextParts.length > 0 && !shouldCoordinateRendering;
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onStreamingCandidate) {
return;
}
if (!shouldReserveAnimationSpace) {
if (hasRequestedReservationRef.current) {
if (hasReasoningParts && resolvedAnimationHandlers?.onReasoningBlock) {
resolvedAnimationHandlers.onReasoningBlock();
} else if (resolvedAnimationHandlers?.onReservationCancelled) {
resolvedAnimationHandlers.onReservationCancelled();
}
hasRequestedReservationRef.current = false;
}
return;
}
if (hasTriggeredReservationOnceRef.current) {
return;
}
hasTriggeredReservationOnceRef.current = true;
resolvedAnimationHandlers.onStreamingCandidate();
hasRequestedReservationRef.current = true;
}, [resolvedAnimationHandlers, shouldReserveAnimationSpace, hasReasoningParts]);
React.useEffect(() => {
if (!resolvedAnimationHandlers?.onAnimationStart) {
return;
}
if (!allowAnimation) {
return;
}
if (animationStartNotifiedRef.current) {
return;
}
resolvedAnimationHandlers.onAnimationStart();
animationStartNotifiedRef.current = true;
}, [resolvedAnimationHandlers, allowAnimation]);
React.useEffect(() => {
if (isUser) {
return;
}
const handler = resolvedAnimationHandlers?.onAnimatedHeightChange;
if (!handler) {
return;
}
const shouldTrackHeight = allowAnimation || shouldReserveAnimationSpace;
if (!shouldTrackHeight) {
return;
}
const element = messageContainerRef.current;
if (!element) {
return;
}
if (typeof window === 'undefined' || typeof ResizeObserver === 'undefined') {
handler(element.getBoundingClientRect().height);
return;
}
let rafId: number | null = null;
const notifyHeight = (height: number) => {
if (typeof window === 'undefined') {
handler(height);
return;
}
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
}
rafId = window.requestAnimationFrame(() => {
handler(height);
});
};
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) {
return;
}
notifyHeight(entry.contentRect.height);
});
observer.observe(element);
notifyHeight(element.getBoundingClientRect().height);
return () => {
if (rafId !== null) {
window.cancelAnimationFrame(rafId);
rafId = null;
}
observer.disconnect();
};
}, [allowAnimation, isUser, resolvedAnimationHandlers, shouldReserveAnimationSpace]);
if (shouldHideUserMessage) {
return null;
@@ -1070,13 +935,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1106,13 +969,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={false}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
showReasoningTraces={showReasoningTraces}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
agentMention={agentMention}
onRevert={handleRevert}
onFork={isUser ? handleFork : undefined}
@@ -1152,12 +1013,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
onShowPopup={handleShowPopup}
streamPhase={streamPhase}
allowAnimation={allowAnimation}
onContentChange={onContentChange}
shouldShowHeader={shouldShowHeader}
hasTextContent={hasTextContent}
onCopyMessage={handleCopyMessage}
copiedMessage={copiedMessage}
onAuxiliaryContentComplete={handleAuxiliaryContentComplete}
showReasoningTraces={showReasoningTraces}
agentMention={agentMention}
turnGroupingContext={turnGroupingContext}
@@ -0,0 +1,305 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The bar that sits in the composer stack: pending-changes accessory, abort
// status, and the todos dropdown. Deliberately a separate component from
// StatusRow — that one is the floating assistant-status chip above the
// composer, and sharing markup meant every restyle of the chip (glass,
// placement) silently restyled this bar and its dropdown too.
type TodoItem = Todo & { id?: string };
const COMPOSER_STATUS_BAR_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "composer-status-bar" };
const statusConfig = {
in_progress: { textClassName: "text-foreground" },
pending: { textClassName: "text-foreground" },
completed: { textClassName: "text-muted-foreground line-through" },
cancelled: { textClassName: "text-muted-foreground line-through" },
};
const priorityClassName = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true" />,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true" />,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true" />,
};
const statusLabelKey = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
// SAFETY: todo.status / todo.priority arrive from the SDK as open strings;
// lookups treat them as candidate keys and every call site falls back to a
// default entry when the value is outside the known set.
const knownStatus = (status: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
status as keyof typeof statusConfig;
const knownPriority = (priority: string) =>
// SAFETY: candidate-key narrowing; misses resolve to undefined and callers fall back.
priority as keyof typeof priorityClassName;
const TodoItemRow: React.FC<{ todo: TodoItem }> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[knownStatus(todo.status)] || statusConfig.pending;
// SAFETY: the label keys are literal members of the i18n dictionary; the
// lookup narrows an open SDK string with a known fallback, and t() accepts
// only the generated key union.
const statusKey = (statusLabelKey[knownStatus(todo.status)] ?? statusLabelKey.pending) as Parameters<typeof t>[0];
// SAFETY: same literal-member narrowing as statusKey above.
const priorityKey = (priorityLabelKey[knownPriority(todo.priority)] ?? priorityLabelKey.medium) as Parameters<typeof t>[0];
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true" />
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true" />
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey)}
</TooltipContent>
</Tooltip>
<span className={cn("flex-1 typography-ui-label", config.textClassName)}>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[knownPriority(todo.priority)] ?? priorityClassName.medium,
)}
>
{priorityIcon[knownPriority(todo.priority)] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface ComposerStatusBarProps {
showAbortStatus?: boolean;
showTodos?: boolean;
leftAccessory?: React.ReactNode;
}
export const ComposerStatusBar: React.FC<ComposerStatusBarProps> = ({
showAbortStatus,
showTodos = true,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((todo) => todo.status === "in_progress") ||
visibleTodos.find((todo) => todo.status === "pending") ||
null
);
}, [visibleTodos]);
const progress = React.useMemo(() => {
const total = todos.filter((todo) => todo.status !== "cancelled").length;
const completed = todos.filter((todo) => todo.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((todo) => todo.status === "in_progress").length;
const left = visibleTodos.filter((todo) => todo.status === "in_progress" || todo.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasLeftAccessory = Boolean(leftAccessory);
const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory;
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
// SAFETY: mousedown targets are DOM nodes; contains() only needs Node.
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{!isCompact && activeTodo ? (
<span className="composer-status-bar__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
if (!hasContent) {
return null;
}
return (
<div className="mb-2" style={COMPOSER_STATUS_BAR_CONTAINER_STYLE}>
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: abort status | pending-changes accessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true" />
{t('chat.statusRow.aborted')}
</span>
</div>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: todos dropdown */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory && "pr-1.5")} ref={popoverRef}>
{todoTrigger}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150",
)}
>
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useRef, memo } from 'react';
import { useInputStore } from '@/sync/input-store';
import type { AttachedFile } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { openExternalUrl } from '@/lib/url';
@@ -833,7 +834,10 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup, compact = false }
<button
type="button"
onClick={() => {
useUIStore.getState().navigateToDiagram(filePath);
const directory = useDirectoryStore.getState().currentDirectory;
if (directory) {
useUIStore.getState().openContextFile(directory, filePath);
}
}}
className={cn(
"flex items-center gap-2 p-2 rounded-lg border border-border/40 bg-muted/10 hover:bg-muted/20 transition-colors text-left cursor-pointer",
@@ -14,6 +14,8 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { mentionServerQuery, rankFileMentionResults } from './fileMentionResults';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit;
@@ -94,14 +96,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
].filter((value): value is string => typeof value === 'string' && value.length > 0);
const seen = new Set<string>();
const queryLower = normalizedSearchQuery.toLowerCase();
const mapped = ordered
.filter((filePath) => {
if (seen.has(filePath)) return false;
seen.add(filePath);
const relative = filePath.startsWith(`${projectRoot}/`) ? filePath.slice(projectRoot.length + 1) : filePath;
if (!queryLower) return true;
return relative.toLowerCase().includes(queryLower);
return matchesRankQuery([relative], normalizedSearchQuery);
})
.slice(0, 6)
.map((filePath) => {
@@ -124,9 +124,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
() => normalizedSearchQuery.length > 0 ? agents : agents.slice(0, 2),
[agents, normalizedSearchQuery.length],
);
const visibleDirectories = directories;
const visibleRecentFiles = recentFiles;
const visibleFiles = files;
const visibleResults = React.useMemo(
() => rankFileMentionResults(files, directories, normalizedSearchQuery, 20),
[files, directories, normalizedSearchQuery],
);
React.useEffect(() => {
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
@@ -152,13 +154,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
if (!normalizedQueryLower) {
if (!serverQuery) {
setFiles([]);
return;
}
@@ -167,7 +165,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 80, {
searchFiles(currentDirectory, serverQuery, 80, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'file',
@@ -178,7 +176,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
const recentSet = new Set(recentFiles.map((file) => file.path));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)).slice(0, 15));
setFiles(hits.filter((hit) => !recentSet.has(hit.path)));
})
.catch(() => {
if (!cancelled) {
@@ -210,13 +208,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const normalizedQuery = (debouncedQuery ?? '').trim();
const normalizedQueryLower = normalizedQuery
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase();
const serverQuery = mentionServerQuery(debouncedQuery ?? '');
if (!normalizedQueryLower) {
if (!serverQuery) {
setDirectories([]);
return;
}
@@ -225,14 +219,14 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
pendingSearchRef.current++;
setLoading(true);
searchFiles(currentDirectory, normalizedQueryLower, 20, {
searchFiles(currentDirectory, serverQuery, 20, {
includeHidden: showHidden,
respectGitignore: !showGitignored,
type: 'directory',
})
.then((hits) => {
if (!cancelled) {
setDirectories(hits.slice(0, 10));
setDirectories(hits);
}
})
.catch(() => {
@@ -261,28 +255,22 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
React.useEffect(() => {
const visibleAgents = getVisibleAgents();
const normalizedQuery = (searchQuery ?? '').trim().toLowerCase();
const filtered = visibleAgents
const subagents = visibleAgents
.filter((agent) => agent.mode && agent.mode !== 'primary')
.filter((agent) => {
if (!normalizedQuery) return true;
const haystack = `${agent.name} ${agent.description ?? ''}`.toLowerCase();
return haystack.includes(normalizedQuery);
})
.map((agent) => ({
name: agent.name,
description: agent.description,
mode: agent.mode,
}))
.sort((a, b) => a.name.localeCompare(b.name));
setAgents(filtered);
setAgents(rankByQuery(subagents, searchQuery ?? '', (agent) => [agent.name, agent.description]));
}, [getVisibleAgents, searchQuery]);
React.useEffect(() => {
setSelectedIndex(0);
setOverflowMap({});
setMarqueeDurations({});
}, [visibleFiles, visibleDirectories, visibleRecentFiles.length, visibleAgents.length]);
}, [visibleResults, visibleRecentFiles.length, visibleAgents.length]);
React.useEffect(() => {
selectedIndexRef.current = selectedIndex;
@@ -332,7 +320,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
window.removeEventListener('resize', updateOverflow);
};
}, [visibleFiles, visibleDirectories]);
}, [visibleResults]);
React.useEffect(() => {
const labelNode = labelRefs.current[selectedIndex];
@@ -376,7 +364,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
return;
}
const total = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + visibleFiles.length;
const total = visibleAgents.length + visibleRecentFiles.length + visibleResults.length;
if (total === 0) {
return;
}
@@ -400,24 +388,16 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
}
return;
}
const dirIndex = safeIndex - visibleAgents.length;
if (dirIndex < visibleDirectories.length) {
const dir = visibleDirectories[dirIndex];
if (dir) {
handleFileSelect(dir);
}
return;
}
const fileIndex = dirIndex - visibleDirectories.length;
const selectedFile = fileIndex < visibleRecentFiles.length
? visibleRecentFiles[fileIndex]
: visibleFiles[fileIndex - visibleRecentFiles.length];
const recentIndex = safeIndex - visibleAgents.length;
const selectedFile = recentIndex < visibleRecentFiles.length
? visibleRecentFiles[recentIndex]
: visibleResults[recentIndex - visibleRecentFiles.length];
if (selectedFile) {
handleFileSelect(selectedFile);
}
}
}
}), [visibleFiles, visibleDirectories, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
}), [visibleResults, visibleRecentFiles, visibleAgents, onClose, handleFileSelect, handleAgentPick]);
const getFileIcon = (file: FileInfo) => {
const ext = file.extension?.toLowerCase();
@@ -482,38 +462,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{t('chat.fileMentionAutocomplete.searchMoreAgents')}
</div>
)}
{visibleAgents.length > 0 && (visibleDirectories.length > 0 || visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleDirectories.map((dir, index) => {
const rowIndex = visibleAgents.length + index;
const relativePath = dir.relativePath || dir.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
return (
<div
key={`dir-${dir.path}`}
ref={(el) => { itemRefs.current[rowIndex] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label rounded-lg",
isSelected && "bg-interactive-selection"
)}
onClick={() => handleFileSelect(dir)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
<Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
<span className="flex-1 min-w-0 truncate" aria-label={relativePath}>
{displayPath}
</span>
</div>
);
})}
{visibleDirectories.length > 0 && (visibleRecentFiles.length > 0 || visibleFiles.length > 0) && (
{visibleAgents.length > 0 && (visibleRecentFiles.length > 0 || visibleResults.length > 0) && (
<div className="my-1 border-t border-border/60" />
)}
{visibleRecentFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + index;
const rowIndex = visibleAgents.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -561,11 +514,11 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
</div>
);
})}
{visibleRecentFiles.length > 0 && visibleFiles.length > 0 && (
{visibleRecentFiles.length > 0 && visibleResults.length > 0 && (
<div className="my-1 border-t border-border/60" />
)}
{visibleFiles.map((file, index) => {
const rowIndex = visibleAgents.length + visibleDirectories.length + visibleRecentFiles.length + index;
{visibleResults.map((file, index) => {
const rowIndex = visibleAgents.length + visibleRecentFiles.length + index;
const relativePath = file.relativePath || file.name;
const displayPath = truncatePathMiddle(relativePath, { maxLength: 60 });
const isSelected = selectedIndex === rowIndex;
@@ -582,7 +535,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
onClick={() => handleFileSelect(file)}
onMouseMove={() => setSelectedIndex(rowIndex)}
>
{getFileIcon(file)}
{file.kind === 'directory'
? <Icon name="folder-3-fill" className="h-3.5 w-3.5 text-primary/60" />
: getFileIcon(file)}
<span
ref={(el) => { labelRefs.current[rowIndex] = el; }}
className="relative flex-1 min-w-0 overflow-hidden file-mention-marquee-container"
@@ -613,12 +568,12 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
);
return (
<React.Fragment key={file.path}>
<React.Fragment key={`${file.kind}-${file.path}`}>
{item}
</React.Fragment>
);
})}
{visibleFiles.length === 0 && visibleDirectories.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
{visibleResults.length === 0 && visibleRecentFiles.length === 0 && visibleAgents.length === 0 && (
<div className="px-3 py-2 typography-ui-label text-muted-foreground">
{t('chat.fileMentionAutocomplete.empty')}
</div>
@@ -345,6 +345,13 @@ const useFileReferenceInteractions = ({
if (!container) {
return;
}
// Wait for the real directory: annotating against an empty/fallback
// directory issues stat probes under the wrong cache key (and the wrong
// server directory), and the pass reruns anyway once the directory
// resolves — every link ended up verified twice.
if (enabled && !effectiveDirectory) {
return;
}
let cancelled = false;
const fileReferenceLinkLimit = getFileReferenceLinkLimit();
// On mobile surfaces, file-reference highlighting is disabled entirely — not
@@ -398,6 +405,19 @@ const useFileReferenceInteractions = ({
};
const annotateFileLinks = () => {
annotationWriteDepth += 1;
try {
annotateFileLinksInner();
} finally {
// Let the mutation events from our own writes flush before the
// observer starts listening for real content changes again.
queueMicrotask(() => {
annotationWriteDepth -= 1;
});
}
};
const annotateFileLinksInner = () => {
if (fileReferencesEnabled) {
wrapBlockCodePathTokens(container);
}
@@ -526,7 +546,12 @@ const useFileReferenceInteractions = ({
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
// Our own annotation writes (path-token wrapping, attribute updates) fire
// childList mutations too; observing them re-ran the whole pass — every
// link was scanned and verified twice per render.
let annotationWriteDepth = 0;
const observer = new MutationObserver(() => {
if (annotationWriteDepth > 0) return;
scheduleAnnotation(FILE_REFERENCE_ANNOTATION_DELAY_MS);
});
observer.observe(container, {
@@ -818,19 +843,39 @@ const useMorphdomMarkdown = ({
// Reconcile per block: only re-morph blocks whose content changed, leaving
// stable leading blocks untouched. Keeps per-stream-step DOM work bounded
// to the trailing (growing) block instead of the whole message.
let enteredThisPass = 0;
blocks.forEach((block, index) => {
let el = existing[index];
let isNewBlock = false;
if (!el) {
el = document.createElement('div');
el.setAttribute('data-md-block', '');
el.style.display = 'contents';
target.appendChild(el);
isNewBlock = true;
}
if (el.getAttribute('data-md-id') === block.id) return;
const temp = document.createElement('div');
temp.innerHTML = block.html;
decorateMarkdown(temp, ctx);
if (isNewBlock && streaming && index > 0) {
// A freshly committed block enters with a short reveal. The class
// goes on the block's children — the wrapper is display:contents
// and cannot animate — and the transform never changes layout, so
// row measurement stays exact. Skipped for the first block so a
// full initial render does not shimmer. Several blocks committed
// in one tick cascade with a small stagger instead of popping in
// together.
const delayMs = Math.min(enteredThisPass, 4) * 55;
enteredThisPass += 1;
for (const child of Array.from(temp.children)) {
child.classList.add('oc-md-block-enter');
if (delayMs > 0 && child instanceof HTMLElement) {
child.style.setProperty('--oc-md-enter-delay', `${delayMs}ms`);
}
}
}
const hadMermaidBlock = shouldRefreshMermaidViewers(el);
const tempHasMermaidBlock = shouldRefreshMermaidViewers(temp);
morphdom(el, temp, {
File diff suppressed because it is too large Load Diff
@@ -25,7 +25,8 @@ import { useDeviceInfo } from '@/lib/device';
import { mergeModelMetadataWithLiveModel } from '@/lib/modelMetadata';
import { getModelDisplayName as getSharedModelDisplayName } from '@/lib/modelDisplay';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
import { cn, fuzzyMatch } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { useContextStore } from '@/stores/contextStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -528,13 +529,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const sortedAndFilteredAgents = React.useMemo(() => {
const sorted = [...selectableDesktopAgents].sort((a, b) => a.name.localeCompare(b.name));
if (!agentSearchQuery.trim()) {
return sorted;
}
return sorted.filter((agent) =>
fuzzyMatch(agent.name, agentSearchQuery) ||
(agent.description && fuzzyMatch(agent.description, agentSearchQuery))
);
return rankByQuery(sorted, agentSearchQuery, (agent) => [agent.name, agent.description]);
}, [selectableDesktopAgents, agentSearchQuery]);
const defaultAgentName = React.useMemo(() => {
@@ -580,38 +575,10 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
return result;
}, [providers, hiddenModels]);
const normalizeModelSearchValue = React.useCallback((value: string) => {
const lower = value.toLowerCase().trim();
const compact = lower.replace(/[^a-z0-9]/g, '');
const tokens = lower.split(/[^a-z0-9]+/).filter(Boolean);
return { lower, compact, tokens };
}, []);
const matchesModelSearch = React.useCallback((candidate: string, query: string) => {
const normalizedQuery = normalizeModelSearchValue(query);
if (!normalizedQuery.lower) {
return true;
}
const normalizedCandidate = normalizeModelSearchValue(candidate);
if (normalizedCandidate.lower.includes(normalizedQuery.lower)) {
return true;
}
if (normalizedQuery.compact.length >= 2 && normalizedCandidate.compact.includes(normalizedQuery.compact)) {
return true;
}
if (normalizedQuery.tokens.length === 0) {
return false;
}
return normalizedQuery.tokens.every((queryToken) =>
normalizedCandidate.tokens.some((candidateToken) =>
candidateToken.startsWith(queryToken) || candidateToken.includes(queryToken)
)
);
}, [normalizeModelSearchValue]);
const matchesModelSearch = React.useCallback(
(candidate: string, query: string) => matchesRankQuery([candidate], query),
[],
);
const currentModelForMetadata = currentModelId
? models.find((model: ProviderModel) => model.id === currentModelId)
@@ -107,7 +107,7 @@ export const PendingChangesBar: React.FC = React.memo(() => {
>
<Icon name="file-edit" className="h-3.5 w-3.5 flex-shrink-0 text-[var(--status-warning)]" />
<span className="min-w-0 typography-ui-label text-foreground flex-shrink-0">{labelHead}</span>
<span className="status-row__changed-label min-w-0 typography-ui-label text-foreground truncate">
<span className="composer-status-bar__changed-label min-w-0 typography-ui-label text-foreground truncate">
{t('chat.pendingChanges.changedInWorkspace')}
</span>
<span className="text-[0.75rem] tabular-nums inline-flex items-baseline gap-1 flex-shrink-0">
+17 -320
View File
@@ -1,123 +1,18 @@
import React from "react";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { cn } from "@/lib/utils";
import { useDirectorySync } from "@/sync/sync-context";
import type { Todo } from "@opencode-ai/sdk/v2/client";
// Compat aliases for old TodoItem shape
type TodoItem = Todo & { id?: string };
type TodoStatus = string;
type TodoPriority = string;
import { useUIStore } from "@/stores/useUIStore";
import { useTodosPersistStore } from "@/stores/useTodosPersistStore";
import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder";
import { isVSCodeRuntime } from "@/lib/desktop";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Icon } from "@/components/icon/Icon";
import { useI18n } from "@/lib/i18n";
// The floating assistant-status chip that hovers above the composer while the
// agent works ("Claude is working…", abort notice). ONLY that. The composer's
// own bar — pending changes, todos dropdown — is ComposerStatusBar: they used
// to share this component, and every restyle of this chip (glass, placement)
// silently dragged the composer bar and its dropdown along with it.
const STATUS_ROW_CONTAINER_STYLE = { containerType: "inline-size" as const, containerName: "status-row" };
const statusConfig: Record<TodoStatus, { textClassName: string }> = {
in_progress: {
textClassName: "text-foreground",
},
pending: {
textClassName: "text-foreground",
},
completed: {
textClassName: "text-muted-foreground line-through",
},
cancelled: {
textClassName: "text-muted-foreground line-through",
},
};
const priorityClassName: Record<TodoPriority, string> = {
high: "text-[var(--status-warning)]",
medium: "text-muted-foreground",
low: "text-muted-foreground/70",
};
const priorityIcon: Record<TodoPriority, React.ReactNode> = {
high: <Icon name="arrow-up-double" className="h-3.5 w-3.5" aria-hidden="true"/>,
medium: <Icon name="arrow-up-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
low: <Icon name="arrow-down-s" className="h-3.5 w-3.5" aria-hidden="true"/>,
};
const statusLabelKey: Record<TodoStatus, string> = {
in_progress: "chat.statusRow.todo.status.inProgress",
pending: "chat.statusRow.todo.status.pending",
completed: "chat.statusRow.todo.status.completed",
cancelled: "chat.statusRow.todo.status.cancelled",
};
const priorityLabelKey: Record<TodoPriority, string> = {
high: "chat.statusRow.todo.priority.high",
medium: "chat.statusRow.todo.priority.medium",
low: "chat.statusRow.todo.priority.low",
};
interface TodoItemRowProps {
todo: TodoItem;
}
const TodoItemRow: React.FC<TodoItemRowProps> = ({ todo }) => {
const { t } = useI18n();
const config = statusConfig[todo.status] || statusConfig.pending;
const statusKey = statusLabelKey[todo.status] ?? statusLabelKey.pending;
const priorityKey = priorityLabelKey[todo.priority] ?? priorityLabelKey.medium;
const statusIcon =
todo.status === "in_progress" ? (
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" aria-hidden="true"/>
) : todo.status === "completed" ? (
<Icon name="checkbox-circle" className="h-3.5 w-3.5 text-[var(--status-success)]" aria-hidden="true"/>
) : (
<Icon name="time" className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true"/>
);
return (
<div className="flex items-center min-w-0 py-0.5 gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-shrink-0">{statusIcon}</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={6}>
{t(statusKey as never)}
</TooltipContent>
</Tooltip>
<span
className={cn(
"flex-1 typography-ui-label",
config.textClassName
)}
>
{todo.content}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"typography-meta flex items-center justify-center flex-shrink-0 leading-none",
priorityClassName[todo.priority] ?? priorityClassName.medium
)}
>
{priorityIcon[todo.priority] ?? priorityIcon.medium}
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={6}>
{t(priorityKey as never)}
</TooltipContent>
</Tooltip>
</div>
);
};
const EMPTY_TODOS: TodoItem[] = [];
interface StatusRowProps {
// Working state
isWorking?: boolean;
statusText?: string | null;
isGenericStatus?: boolean;
@@ -125,17 +20,10 @@ interface StatusRowProps {
wasAborted?: boolean;
abortActive?: boolean;
retryInfo?: { attempt?: number; next?: number } | null;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
// Abort status display
showAbortStatus?: boolean;
showAssistantStatus?: boolean;
showTodos?: boolean;
agentName?: string;
modelName?: string | null;
providerId?: string | null;
leftAccessory?: React.ReactNode;
}
export const StatusRow: React.FC<StatusRowProps> = ({
@@ -146,192 +34,43 @@ export const StatusRow: React.FC<StatusRowProps> = ({
wasAborted,
abortActive,
retryInfo,
showAbort,
onAbort,
showAbortStatus,
showAssistantStatus = true,
showTodos = true,
agentName,
modelName,
providerId,
leftAccessory,
}) => {
const { t } = useI18n();
const [isExpanded, setIsExpanded] = React.useState(false);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentSessionDirectory = useSessionUIStore(
React.useCallback(
(state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null),
[currentSessionId],
),
);
const liveTodos = useDirectorySync(
React.useCallback(
(state) => {
if (!showTodos || !currentSessionId) return EMPTY_TODOS;
return state.todo[currentSessionId] ?? EMPTY_TODOS;
},
[currentSessionId, showTodos],
),
);
const persistedSessionTodos = useTodosPersistStore(
React.useCallback(
(state) => (showTodos && currentSessionId && currentSessionDirectory
? state.getSessionTodos(currentSessionDirectory, currentSessionId)
: undefined),
[currentSessionDirectory, currentSessionId, showTodos],
),
);
const todos: TodoItem[] = React.useMemo(() => {
if (!currentSessionId) return EMPTY_TODOS;
if (liveTodos.length > 0) return liveTodos;
return persistedSessionTodos ?? EMPTY_TODOS;
}, [liveTodos, persistedSessionTodos, currentSessionId]);
const isMobile = useUIStore((state) => state.isMobile);
const isCompact = isMobile || isVSCodeRuntime();
// Filter out cancelled todos for display and keep original order.
// This prevents items from jumping around when status changes.
const visibleTodos = React.useMemo(() => {
return todos.filter((todo) => todo.status !== "cancelled");
}, [todos]);
// Find the current active todo (first in_progress, or first pending)
const activeTodo = React.useMemo(() => {
return (
visibleTodos.find((t) => t.status === "in_progress") ||
visibleTodos.find((t) => t.status === "pending") ||
null
);
}, [visibleTodos]);
// Calculate progress
const progress = React.useMemo(() => {
const total = todos.filter((t) => t.status !== "cancelled").length;
const completed = todos.filter((t) => t.status === "completed").length;
return { completed, total };
}, [todos]);
const statusSummary = React.useMemo(() => {
const active = visibleTodos.filter((t) => t.status === "in_progress").length;
const left = visibleTodos.filter((t) => t.status === "in_progress" || t.status === "pending").length;
return { active, left };
}, [visibleTodos]);
const hasTodoContent = showTodos && statusSummary.left > 0;
const hasAssistantContent = showAssistantStatus && (
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus)
);
const hasLeftAccessory = Boolean(leftAccessory);
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus);
const hasContent = hasAssistantContent || hasTodoContent || hasLeftAccessory;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!isExpanded) return;
const handleClickOutside = (event: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isExpanded]);
const toggleExpanded = () => setIsExpanded((prev) => !prev);
const todoSummaryLabel = t('chat.statusRow.summary.activeLeft', {
active: statusSummary.active,
left: statusSummary.left,
});
// Abort button for mobile/vscode
const abortButton = showAbort && onAbort ? (
<button
type="button"
onClick={onAbort}
className="flex items-center justify-center h-[1.2rem] w-[1.2rem] text-[var(--status-error)] transition-opacity hover:opacity-80 focus-visible:outline-none flex-shrink-0"
aria-label={t('chat.statusRow.actions.stopGeneratingAria')}
>
<Icon name="close-circle" aria-hidden="true"/>
</button>
) : null;
// Todo trigger button
const todoTrigger = hasTodoContent ? (
<button
type="button"
onClick={toggleExpanded}
className="flex items-center gap-1 flex-shrink-0 text-muted-foreground"
aria-label={todoSummaryLabel}
title={todoSummaryLabel}
>
{/* Desktop: show task text; Mobile/VSCode: just "Tasks" */}
{!isCompact && activeTodo ? (
<span className="status-row__active-todo typography-ui-label text-foreground truncate max-w-[200px]">
{activeTodo.content}
</span>
) : (
<span className="typography-ui-label">{t('chat.statusRow.tasksTitle')}</span>
)}
<span className="typography-meta flex items-center gap-1 tabular-nums" aria-hidden="true">
<span className="flex items-center gap-0.5">
<Icon name="record-circle" className="h-3.5 w-3.5 text-[var(--status-info)]" />
{statusSummary.active}
</span>
<span>·</span>
<span className="flex items-center gap-0.5">
<Icon name="time" className="h-3.5 w-3.5" />
{statusSummary.left}
</span>
</span>
{isExpanded ? (
<Icon name="arrow-up-s" className="h-3.5 w-3.5" />
) : (
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
)}
</button>
) : null;
// Don't render if nothing to show
if (!hasContent) {
return null;
}
return (
<div
// This row must land exactly where the assistant turn footer (mt-2
// inside the message) appears when the turn completes. Measured against
// the live DOM: the gap ABOVE already matches (message pb-2 = footer
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
// carries ~12px more structure BELOW its footer than this row has — so
// the swap used to lift the line up. mb-6 (24px) reserves that space
// under this row instead (verified: row top 636 == footer top 636).
// The reservation belongs to the assistant-status swap only: a row that
// renders just an accessory (the pending-changes bar) takes the normal
// 8px, or it floats a stray gap above the composer.
className={cn(showAssistantStatus ? "mb-6" : "mb-2", !hasLeftAccessory && "chat-column")}
// The row renders inside the composer-anchored overlay, which owns the
// distance to the input and the horizontal column (the same ones the
// scroll-to-bottom pill uses).
style={STATUS_ROW_CONTAINER_STYLE}
>
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? (
{/* The glass chip lives here, not on the container: the root above is
an inline-size query container, whose width ignores its children
a shrink-to-fit wrapper around it always collapsed to zero. */}
<div className="oc-glass-popover inline-flex w-max max-w-full items-center gap-2 h-8 whitespace-nowrap rounded-full [corner-shape:round] px-3">
<div className="flex items-center min-w-0 gap-2 overflow-x-hidden">
{showAbortStatus ? (
<div className="flex h-full items-center text-[var(--status-error)] pl-0.5">
<span className="flex items-center gap-1.5 typography-ui-label">
<Icon name="close-circle" aria-hidden="true"/>
{t('chat.statusRow.aborted')}
</span>
</div>
) : showAssistantStatus && shouldRenderPlaceholder ? (
) : shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
@@ -343,50 +82,8 @@ export const StatusRow: React.FC<StatusRowProps> = ({
modelName={modelName}
providerId={providerId}
/>
) : leftAccessory ? (
leftAccessory
) : null}
</div>
{/* Right: Abort (mobile only) + Todo */}
<div className={cn("relative flex items-center gap-2 flex-shrink-0", hasLeftAccessory ? "pr-1.5" : "-mr-3")} ref={popoverRef}>
{abortButton}
{todoTrigger}
{/* Popover dropdown */}
{isExpanded && hasTodoContent && (
<div
style={{
maxWidth: "min(28rem, calc(100cqw - 4ch))",
backgroundColor: "var(--surface-elevated)",
color: "var(--surface-elevated-foreground)",
}}
className={cn(
"absolute right-0 bottom-full mb-1 z-50",
"w-max min-w-[200px] rounded-xl p-1",
"shadow-[inset_0_1px_0_0_rgba(255,255,255,0.8),inset_0_0_0_1px_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.10),0_1px_2px_-0.5px_rgba(0,0,0,0.08),0_4px_8px_-2px_rgba(0,0,0,0.08),0_12px_20px_-4px_rgba(0,0,0,0.08)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.12),inset_0_0_0_1px_rgba(255,255,255,0.08),0_0_0_1px_rgba(0,0,0,0.36),0_1px_1px_-0.5px_rgba(0,0,0,0.22),0_3px_3px_-1.5px_rgba(0,0,0,0.20),0_6px_6px_-3px_rgba(0,0,0,0.16)]",
"animate-in fade-in-0 zoom-in-95 slide-in-from-bottom-2",
"duration-150"
)}
>
{/* Header */}
<div className="flex items-center gap-1.5 px-2 py-1 typography-ui-label font-medium text-muted-foreground">
<span>{t('chat.statusRow.tasksTitle')}</span>
<span className="typography-meta tabular-nums">
{progress.completed}/{progress.total}
</span>
</div>
{/* Todo list */}
<div className="px-1 max-h-[200px] overflow-y-auto">
{visibleTodos.map((todo, index) => (
<TodoItemRow key={todo.id ?? `todo-${index}`} todo={todo} />
))}
</div>
</div>
)}
</div>
</div>
</div>
);
@@ -46,8 +46,6 @@ export const StatusRowContainer: React.FC = React.memo(() => {
wasAborted={wasAborted || working.wasAborted}
abortActive={wasAborted || working.abortActive}
retryInfo={working.retryInfo}
showAssistantStatus
showTodos={false}
agentName={currentAgentName}
modelName={modelDisplayName}
providerId={activeModel?.providerId ?? null}
@@ -26,9 +26,6 @@ import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
const NOOP_CONTENT_CHANGE = (): void => {};
/**
* The `/btw` peek panel.
*
@@ -446,7 +443,6 @@ const BtwMessages: React.FC<{
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
@@ -1,33 +1,85 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { useConfigStore } from '@/stores/useConfigStore';
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
/**
* Compact one-line mirror of the status row for the pill: same label, none of
* the status row's animation machinery (which does not survive being squeezed
* into a 32px chip).
*/
const PillWorkingStatus: React.FC = () => {
const { t } = useI18n();
const { activeModel, working } = useAssistantStatus();
const providers = useConfigStore((state) => state.providers);
const modelName = React.useMemo(() => {
if (!activeModel) return null;
const provider = providers.find((candidate) => candidate.id === activeModel.providerId);
return getProviderModelDisplayName(provider, activeModel.modelId) || null;
}, [activeModel, providers]);
if (!working.isWorking || !working.statusText) return null;
const status = working.statusText;
const label = modelName && modelName.trim().length > 0
? t('chat.statusRow.modelStatus', { model: modelName.trim(), status })
: status.charAt(0).toUpperCase() + status.slice(1);
return (
<span className="min-w-0 truncate pr-3 text-sm text-muted-foreground">
{label}
<span className="animate-pulse"> </span>
</span>
);
};
interface ScrollToBottomButtonProps {
visible: boolean;
/** The session is still streaming: the pill carries the status label
while the floating status row is hidden away from the live edge. */
working?: boolean;
onClick: () => void;
}
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, onClick }) => {
const ScrollToBottomButton: React.FC<ScrollToBottomButtonProps> = ({ visible, working = false, onClick }) => {
const { t } = useI18n();
return (
<div
className={cn(
'absolute bottom-full left-1/2 -translate-x-1/2 mb-2 transition-all duration-150',
visible ? 'opacity-100 translate-y-0 scale-100 pointer-events-auto' : 'opacity-0 translate-y-2 scale-95 pointer-events-none',
'pointer-events-none absolute bottom-full inset-x-0 mb-2 transition-opacity duration-100',
visible ? 'opacity-100' : 'opacity-0',
)}
>
<Button
variant="outline"
size="sm"
onClick={onClick}
className="size-8 rounded-full [corner-shape:round] p-0 shadow-none bg-background/95 hover:bg-interactive-hover"
aria-label={t('chat.scrollToBottom.aria')}
>
<Icon name="arrow-down" className="h-4 w-4" />
</Button>
{/* The same column that centres the composer, so the pill's left
edge lines up exactly with the input frame. */}
<div className="chat-input-column">
{/* The soft shadow lives on this wrapper, away from the glass
button's backdrop-filter: sharing one element made the
shadow intermittently drop after hide/show cycles. */}
<div className="inline-flex max-w-full rounded-full shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.10)] dark:shadow-[0_2px_6px_-2px_rgb(0_0_0_/_0.35)]">
<button
type="button"
onClick={onClick}
aria-label={t('chat.scrollToBottom.aria')}
className={cn(
// Glass material with a hairline real border — much
// lighter than the oc-glass-floating stack.
'oc-glass-popover inline-flex h-8 max-w-full items-center rounded-full [corner-shape:round] text-left',
'border border-black/[0.06] dark:border-white/[0.08]',
visible ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center text-muted-foreground">
<Icon name="arrow-down" className="h-4 w-4" />
</span>
{working && visible ? <PillWorkingStatus /> : null}
</button>
</div>
</div>
</div>
);
};
@@ -4,7 +4,6 @@ import ProgressiveGroup from '../message/parts/ProgressiveGroup';
import type { TurnActivityRecord } from '../lib/turns/types';
import type { ToolPopupContent } from '../message/types';
import type { StreamPhase } from '../message/types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
interface DiffStats {
additions: number;
@@ -21,7 +20,6 @@ interface TurnActivityProps {
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -23,6 +23,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { useI18n } from '@/lib/i18n';
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
import { createWorktreeDraft } from '@/lib/worktreeSessionCreator';
import type { Theme } from '@/types/theme';
@@ -264,13 +265,7 @@ export function MobileDraftTargetSheets(
className="h-9"
/>
<div className="flex flex-col">
{projects
.filter((project) => {
const needle = query.trim().toLowerCase();
if (!needle) return true;
return getProjectDisplayLabel(project).toLowerCase().includes(needle)
|| project.path.toLowerCase().includes(needle);
})
{rankByQuery(projects, query, (project) => [getProjectDisplayLabel(project), project.path])
.map((project) => (
<button
key={project.id}
@@ -304,8 +299,7 @@ export function MobileDraftTargetSheets(
/>
<div className="flex flex-col">
{(() => {
const needle = query.trim().toLowerCase();
const matches = (label: string) => !needle || label.toLowerCase().includes(needle);
const matches = (label: string) => matchesRankQuery([label], query);
const selectedValue = selectedDirectory
?? branchItems[0]?.value
?? normalizePath(selectedProject.path)
@@ -349,8 +343,7 @@ export function MobileDraftTargetSheets(
{t('chat.chatInput.worktreeNew')}
</button>
</div>
{worktreeBranchOptions
.filter((option) => matches(option.label))
{rankByQuery(worktreeBranchOptions, query, (option) => [option.label])
.map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))}
{selectedDirectory && !selectedBranchIsKnown && matches(selectedBranchLabel ?? '')
? renderRow(selectedDirectory, selectedBranchLabel, 'unknown-current')
@@ -0,0 +1,53 @@
import { describe, expect, test } from 'bun:test';
import { mentionServerQuery, rankFileMentionResults, tokenizeMentionQuery } from './fileMentionResults';
const hit = (relativePath: string) => {
const name = relativePath.split('/').filter(Boolean).pop() ?? relativePath;
return {
name,
path: `/root/${relativePath}`,
relativePath,
extension: name.includes('.') ? name.split('.').pop()?.toLowerCase() : undefined,
};
};
describe('tokenizeMentionQuery', () => {
test('normalizes leading ./ and slashes and splits on whitespace', () => {
expect(tokenizeMentionQuery('./Solo Team')).toEqual(['solo', 'team']);
expect(tokenizeMentionQuery(' ')).toEqual([]);
});
});
describe('mentionServerQuery', () => {
test('uses the longest token for the server search', () => {
expect(mentionServerQuery('team solo-is-a')).toBe('solo-is-a');
expect(mentionServerQuery('')).toBe('');
});
});
describe('rankFileMentionResults', () => {
test('ranks files and directories together by match quality, not by category', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const directories = [hit('machine-learning/tensorflow/'), hit('solo-is-a-team-size/')];
const ranked = rankFileMentionResults(files, directories, 'solo');
const paths = ranked.map((entry) => entry.relativePath);
expect(paths.slice(0, 2)).toEqual(['solo-is-a-team-size/', 'solo-is-a-team-size/index.md']);
expect(paths).not.toContain('machine-learning/tensorflow/');
});
test('multi-token queries match tokens in any order across the path', () => {
const files = [hit('solo-is-a-team-size/index.md'), hit('software-developer/index.md')];
const ranked = rankFileMentionResults(files, [], 'team solo');
expect(ranked.map((entry) => entry.relativePath)).toEqual(['solo-is-a-team-size/index.md']);
});
test('tags each result with its kind', () => {
const ranked = rankFileMentionResults([hit('a/readme.md')], [hit('a/')], 'a');
expect(ranked.find((entry) => entry.relativePath === 'a/')?.kind).toBe('directory');
expect(ranked.find((entry) => entry.relativePath === 'a/readme.md')?.kind).toBe('file');
});
});
@@ -0,0 +1,60 @@
import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch';
import type { ProjectFileSearchHit } from '@/lib/opencode/client';
export type FileMentionHit = ProjectFileSearchHit & { kind: 'file' | 'directory' };
export const tokenizeMentionQuery = (query: string): string[] =>
(query ?? '')
.trim()
.replace(/^\.\//, '')
.replace(/^\/+/, '')
.toLowerCase()
.split(/\s+/)
.filter(Boolean);
/**
* The opencode file search takes a single term, so multi-word queries send the
* most selective (longest) token and the remaining tokens filter client-side.
*/
export const mentionServerQuery = (query: string): string => {
const tokens = tokenizeMentionQuery(query);
if (tokens.length === 0) {
return '';
}
return tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
};
/**
* Merge directory and file hits into one list ranked by match quality against
* the full relative path. Multi-token queries require every token to appear
* somewhere in the path, in any order.
*/
export function rankFileMentionResults(
files: ProjectFileSearchHit[],
directories: ProjectFileSearchHit[],
query: string,
limit = 20,
): FileMentionHit[] {
const merged: FileMentionHit[] = [
...directories.map((hit) => ({ ...hit, kind: 'directory' as const })),
...files.map((hit) => ({ ...hit, kind: 'file' as const })),
];
const tokens = tokenizeMentionQuery(query);
if (tokens.length === 0) {
return merged.slice(0, limit);
}
const pathOf = (hit: FileMentionHit) => hit.relativePath || hit.name;
const candidates = tokens.length === 1
? merged
: merged.filter((hit) => {
const haystack = pathOf(hit).toLowerCase();
return tokens.every((token) => haystack.includes(token));
});
const primary = tokens.reduce((longest, token) => (token.length > longest.length ? token : longest));
return scoreByFuzzyQuery(candidates, primary, pathOf, { limit, threshold: 0.4 }).map(
(scored) => scored.item,
);
}
@@ -0,0 +1,227 @@
import { describe, expect, test } from 'bun:test';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
getRowBottom,
resolveChatListAnchoredEndSpace,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
} from './timelineScrollAnchoring';
const buildState = ({
positions,
sizes,
scroll = 0,
scrollLength = 700,
}: {
readonly positions: readonly number[];
readonly sizes: readonly number[];
readonly scroll?: number;
readonly scrollLength?: number;
}): TimelineListMeasurementState => ({
data: positions.map((_, index) => index),
scroll,
scrollLength,
positionAtIndex: (index) => positions[index],
sizeAtIndex: (index) => sizes[index],
});
describe('getRowBottom', () => {
test('measures row bottoms from list row position and size', () => {
const state = buildState({ positions: [0, 120], sizes: [80, 40] });
expect(getRowBottom(state, 1)).toBe(160);
});
test('returns null for unmeasured rows', () => {
const state = buildState({ positions: [0], sizes: [80] });
expect(getRowBottom(state, 5)).toBeNull();
});
test('treats a zero-height row as one pixel tall', () => {
const state = buildState({ positions: [0, 120], sizes: [120, 0] });
expect(getRowBottom(state, 1)).toBe(121);
});
});
describe('getAnchoredTurnMetrics', () => {
test('returns null for an empty timeline', () => {
const state = buildState({ positions: [], sizes: [] });
expect(getAnchoredTurnMetrics({
state,
anchorIndex: 0,
composerOverlayHeight: 180,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
})).toBeNull();
});
test('treats the active turn as fitting when it fits above the composer', () => {
const state = buildState({
positions: [0, 300, 460],
sizes: [240, 80, 140],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(300);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(false);
expect(metrics?.targetScrollToRevealEnd).toBe(36);
expect(metrics?.scrollDeltaToRevealEnd).toBe(36);
});
test('targets the real row end instead of any temporary reserved tail', () => {
const state = buildState({
positions: [0, 1720, 1880],
sizes: [1600, 80, 120],
scroll: 1900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(2000);
expect(metrics?.targetScrollToRevealEnd).toBe(1436);
expect(metrics?.scrollDeltaToRevealEnd).toBe(0);
});
test('reports overflow only for the current anchored turn', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 300],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.turnHeight).toBe(580);
expect(metrics?.usableViewportHeight).toBe(564);
expect(metrics?.overflowsUsableViewport).toBe(true);
});
test('returns the minimal positive scroll delta needed to reveal the turn end', () => {
const state = buildState({
positions: [0, 900, 1180],
sizes: [800, 220, 360],
scroll: 900,
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 180,
anchorOffset: 16,
});
expect(metrics?.lastBottom).toBe(1540);
expect(metrics?.visibleUsableBottom).toBe(1464);
expect(metrics?.scrollDeltaToRevealEnd).toBe(76);
});
test('subtracts composer height from usable viewport height', () => {
const state = buildState({
positions: [0, 300],
sizes: [120, 470],
scrollLength: 700,
});
const withoutComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 0,
anchorOffset: 16,
});
const withComposer = getAnchoredTurnMetrics({
state,
anchorIndex: 1,
composerOverlayHeight: 220,
anchorOffset: 16,
});
expect(withoutComposer?.overflowsUsableViewport).toBe(false);
expect(withComposer?.overflowsUsableViewport).toBe(true);
});
test('clamps an out-of-range anchor index to the last row', () => {
const state = buildState({
positions: [0, 300],
sizes: [240, 80],
scrollLength: 760,
});
const metrics = getAnchoredTurnMetrics({
state,
anchorIndex: 99,
composerOverlayHeight: 0,
anchorOffset: 16,
});
expect(metrics?.anchorTop).toBe(300);
expect(metrics?.turnHeight).toBe(80);
});
});
describe('resolveTimelineIsAtEnd', () => {
test('uses a tight distance band against the full content length', () => {
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1400, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1365, scrollLength: 600 })).toBe(true);
expect(resolveTimelineIsAtEnd({ contentLength: 2000, scroll: 1300, scrollLength: 600 })).toBe(false);
});
test('falls back to the list flags when distances are unavailable', () => {
expect(resolveTimelineIsAtEnd({ isNearEnd: true, isAtEnd: false })).toBe(true);
expect(resolveTimelineIsAtEnd({ isAtEnd: true })).toBe(true);
});
test('reports nothing without a state', () => {
expect(resolveTimelineIsAtEnd(undefined)).toBe(undefined);
});
});
describe('resolveChatListAnchoredEndSpace', () => {
const rows = [{ id: 'a' }, { id: 'b' }, { id: 'a' }];
test('returns nothing when no anchor is set', () => {
expect(resolveChatListAnchoredEndSpace(rows, null, (row) => row.id)).toBe(undefined);
});
test('returns nothing when the anchor is not in the list', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'z', (row) => row.id)).toBe(undefined);
});
test('resolves the last occurrence so a resent message anchors to its live row', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'a', (row) => row.id)).toEqual({
anchorIndex: 2,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
test('honours an explicit anchor offset', () => {
expect(resolveChatListAnchoredEndSpace(rows, 'b', (row) => row.id, { anchorOffset: 40 })).toEqual({
anchorIndex: 1,
anchorOffset: 40,
});
});
});
@@ -0,0 +1,167 @@
// Anchored-turn scroll geometry for the chat timeline.
//
// The timeline has three mutually exclusive scroll modes:
//
// • `following-end` — stay pinned to the live edge as content grows.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into reserved space below it. The
// viewport does NOT move until the turn outgrows the usable viewport.
// • `free-scrolling` — the user took over; nothing moves the scroll
// position until they opt back in.
//
// This module is pure geometry: it reads measurements from the virtualized
// list and answers "how far, if at all, must we scroll to reveal the end of
// the anchored turn". Keeping it free of DOM and React makes the mode machine
// testable without a renderer.
//
// "Usable viewport" is the visible height minus the composer overlay (the
// composer floats over the list) minus the anchor offset, so a turn is only
// considered overflowing when it genuinely cannot be read.
export type TimelineScrollMode = 'following-end' | 'anchoring-new-turn' | 'free-scrolling';
// Distance from the top of the viewport at which an anchored user message
// parks. Small enough to read as "at the top", large enough not to collide
// with the timeline's top fade.
export const CHAT_LIST_ANCHOR_OFFSET = 16;
export interface TimelineListMeasurementState {
readonly data: readonly unknown[];
readonly scroll: number;
readonly scrollLength: number;
readonly positionAtIndex: (index: number) => number | undefined;
readonly sizeAtIndex: (index: number) => number | undefined;
}
export interface AnchoredTurnMetrics {
readonly anchorTop: number;
readonly lastBottom: number;
readonly turnHeight: number;
readonly usableViewportHeight: number;
readonly visibleUsableBottom: number;
readonly overflowsUsableViewport: boolean;
readonly targetScrollToRevealEnd: number;
readonly scrollDeltaToRevealEnd: number;
}
export const getRowBottom = (
state: TimelineListMeasurementState,
index: number,
): number | null => {
const top = state.positionAtIndex(index);
const height = state.sizeAtIndex(index);
if (
typeof top !== 'number'
|| typeof height !== 'number'
|| !Number.isFinite(top)
|| !Number.isFinite(height)
) {
return null;
}
// Rows measured at zero height would make an anchored turn look empty and
// suppress the reveal scroll; treat them as one pixel tall instead.
return top + Math.max(1, height);
};
export const getAnchoredTurnMetrics = ({
state,
anchorIndex,
composerOverlayHeight,
anchorOffset,
}: {
readonly state: TimelineListMeasurementState;
readonly anchorIndex: number;
readonly composerOverlayHeight: number;
readonly anchorOffset: number;
}): AnchoredTurnMetrics | null => {
if (state.data.length === 0) return null;
const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1));
const anchorTop = state.positionAtIndex(boundedAnchorIndex);
// The LAST row bottom, not the content length: the reserved anchored end
// space lives past it, and targeting that reserved tail would scroll the
// real content off the top.
const lastBottom = getRowBottom(state, state.data.length - 1);
if (typeof anchorTop !== 'number' || !Number.isFinite(anchorTop) || lastBottom === null) {
return null;
}
const usableViewportHeight = Math.max(
0,
state.scrollLength - composerOverlayHeight - anchorOffset,
);
const turnHeight = Math.max(0, lastBottom - anchorTop);
const visibleUsableBottom = state.scroll + usableViewportHeight;
const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight);
// Never negative: revealing the end must not scroll the timeline backwards.
const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll);
return {
anchorTop,
lastBottom,
turnHeight,
usableViewportHeight,
visibleUsableBottom,
overflowsUsableViewport: turnHeight > usableViewportHeight,
targetScrollToRevealEnd,
scrollDeltaToRevealEnd,
};
};
// "At the end" for follow purposes is a tight band, not the list's isNearEnd
// (half a viewport): that band hid the scroll-to-bottom pill and re-armed
// follow while the user had genuinely scrolled away, yanking them back on the
// next stream chunk. Distance is measured against the full content length —
// reserved anchored end space included — so a parked anchored turn counts as
// the live edge.
export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40;
export const resolveTimelineIsAtEnd = (
state: {
readonly contentLength?: number;
readonly scroll?: number;
readonly scrollLength?: number;
readonly isNearEnd?: boolean;
readonly isAtEnd?: boolean;
} | undefined,
): boolean | undefined => {
if (!state) return undefined;
const { contentLength, scroll, scrollLength } = state;
if (
typeof contentLength === 'number'
&& typeof scroll === 'number'
&& typeof scrollLength === 'number'
&& Number.isFinite(contentLength)
) {
return contentLength - (scroll + scrollLength) <= TIMELINE_FOLLOW_REARM_THRESHOLD_PX;
}
return state.isNearEnd ?? state.isAtEnd;
};
export interface ChatListAnchoredEndSpace {
readonly anchorIndex: number;
readonly anchorOffset: number;
}
// Finds the anchored row from the BACK of the list: a retried or re-sent
// message id can appear more than once, and the live one is always the last.
export const resolveChatListAnchoredEndSpace = <Item, AnchorId>(
items: readonly Item[],
anchorId: AnchorId | null,
getAnchorId: (item: Item) => AnchorId | null,
options: { readonly anchorOffset?: number } = {},
): ChatListAnchoredEndSpace | undefined => {
if (anchorId === null) return undefined;
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (item !== undefined && getAnchorId(item) === anchorId) {
return {
anchorIndex: index,
anchorOffset: options.anchorOffset ?? CHAT_LIST_ANCHOR_OFFSET,
};
}
}
return undefined;
};
@@ -0,0 +1,39 @@
import { describe, expect, test } from 'bun:test';
import { commitStreamedText } from './streamTextCommit';
describe('commitStreamedText', () => {
test('holds an incomplete short paragraph entirely', () => {
expect(commitStreamedText('An unfinished thought abo')).toBe('');
});
test('commits up to the last complete line', () => {
expect(commitStreamedText('First paragraph.\n\nSecond par')).toBe('First paragraph.\n\n');
});
test('reveals code fences line by line', () => {
const text = '```py\nprint("a")\nprint("b';
expect(commitStreamedText(text)).toBe('```py\nprint("a")\n');
});
test('releases a long held paragraph at the last sentence boundary', () => {
const sentence = 'A finished sentence lives here. ';
const text = sentence.repeat(12) + 'and an unfinished trail';
expect(commitStreamedText(text)).toBe(sentence.repeat(12));
});
test('falls back to the last word boundary without sentences', () => {
const words = 'word '.repeat(70);
const text = words + 'unfinishe';
expect(commitStreamedText(text)).toBe(words);
});
test('keeps unbreakable runs intact rather than splitting them', () => {
const run = 'x'.repeat(400);
expect(commitStreamedText(run)).toBe(run);
});
test('empty input stays empty', () => {
expect(commitStreamedText('')).toBe('');
});
});
@@ -0,0 +1,47 @@
// Block-level streaming reveal.
//
// Token-by-token streaming mutates the trailing paragraph in place on every
// tick: words rewrap, the last line jitters, and the reader's eye fights the
// motion. Committing only up to the last COMPLETE line keeps every rendered
// block immutable once it appears — prose arrives a paragraph at a time (a
// markdown paragraph is one logical line), code fences reveal line by line,
// tables row by row — and the only remaining motion is the follow scroll.
//
// A paragraph with no newline for a long stretch must not stall the stream,
// so once the held tail outgrows a threshold it is committed at the last
// sentence boundary (falling back to the last word boundary).
const HOLD_MAX_CHARS = 320;
const SENTENCE_END = /[.!?…][)"'»”’]?\s/g;
export const commitStreamedText = (text: string): string => {
if (text.length === 0) return text;
const lastNewline = text.lastIndexOf('\n');
const committed = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
const held = text.slice(committed.length);
if (held.length <= HOLD_MAX_CHARS) {
return committed;
}
// The held paragraph got long: release it up to the last finished
// sentence so the block still never mutates mid-sentence.
let lastSentenceEnd = -1;
for (const match of held.matchAll(SENTENCE_END)) {
lastSentenceEnd = match.index + match[0].length;
}
if (lastSentenceEnd > 0) {
return committed + held.slice(0, lastSentenceEnd);
}
// No sentence boundary either (a URL, a very long token run): release up
// to the last word boundary, keeping only the incomplete word held.
const lastSpace = held.lastIndexOf(' ');
if (lastSpace > 0) {
return committed + held.slice(0, lastSpace + 1);
}
return text;
};
@@ -64,8 +64,7 @@ describe('buildLiveStreamingEntry', () => {
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_other',
liveParts: [textPart('part_live', 'live')],
livePartsByMessageId: { assistant_other: [textPart('part_live', 'live')] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -79,8 +78,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [reasoningPart('part_1_live', 'thinking')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -102,8 +100,7 @@ describe('buildLiveStreamingEntry', () => {
const liveParts = [textPart('part_1_live', 'live')];
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts,
livePartsByMessageId: { assistant_1: liveParts },
showTextJustificationActivity: false,
showTurnChangedFiles: false,
});
@@ -121,8 +118,7 @@ describe('buildLiveStreamingEntry', () => {
const synthetic = syntheticTextPart('part_synthetic', 'hidden while streaming');
const next = buildLiveStreamingEntry(entry, {
activeStreamingMessageId: 'assistant_1',
liveParts: [synthetic, visible],
livePartsByMessageId: { assistant_1: [synthetic, visible] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
@@ -131,4 +127,39 @@ describe('buildLiveStreamingEntry', () => {
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual([visible]);
});
test('keeps a finished step message on its live parts after the stream moves on', () => {
const finished = message('assistant_1', 'assistant', 'user_1', []);
const streaming = message('assistant_2', 'assistant', 'user_1', []);
const entry = turnEntry(finished);
if (entry.kind !== 'turn') return;
entry.turn.assistantMessageIds = ['assistant_1', 'assistant_2'];
entry.turn.assistantMessages = [finished, streaming];
const finishedLive = [textPart('part_tool_done', 'tool output')];
const streamingLive = [textPart('part_streaming', 'streaming')];
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: finishedLive, assistant_2: streamingLive },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next.kind).toBe('turn');
if (next.kind !== 'turn') return;
expect(next.turn.assistantMessages[0]?.parts).toEqual(finishedLive);
expect(next.turn.assistantMessages[1]?.parts).toEqual(streamingLive);
});
test('never erases record parts with an empty live array', () => {
const assistant = message('assistant_1', 'assistant', 'user_1', [textPart('part_1', 'kept')]);
const entry = turnEntry(assistant);
const next = buildLiveStreamingEntry(entry, {
livePartsByMessageId: { assistant_1: [] },
showTextJustificationActivity: true,
showTurnChangedFiles: false,
});
expect(next).toBe(entry);
});
});
@@ -15,8 +15,13 @@ export type StreamingTailEntry =
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
type BuildLiveStreamingEntryOptions = {
activeStreamingMessageId: string | null | undefined;
liveParts: Part[];
// Live parts for EVERY message of the streaming tail, not only the one
// currently streaming: when the stream moves to the next step message, the
// previous message's base record can still lag behind the part store, and
// rendering it from that stale snapshot briefly drops its completed tool
// parts — remounting them (and replaying their reveal animation) once the
// record catches up.
livePartsByMessageId: Readonly<Record<string, Part[]>>;
showTextJustificationActivity: boolean;
showTurnChangedFiles: boolean;
mergeHiddenUserTurns?: { planModeEnabled: boolean };
@@ -24,10 +29,12 @@ type BuildLiveStreamingEntryOptions = {
const withLiveParts = (
message: ChatMessageEntry,
activeStreamingMessageId: string,
liveParts: Part[],
livePartsByMessageId: Readonly<Record<string, Part[]>>,
): ChatMessageEntry => {
if (message.info.id !== activeStreamingMessageId || message.parts === liveParts) {
const liveParts = livePartsByMessageId[message.info.id];
// An empty live array is ambiguous — the store may simply not have loaded
// this message's parts — and must never erase parts the record does have.
if (!liveParts || liveParts.length === 0 || message.parts === liveParts) {
return message;
}
@@ -41,13 +48,10 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
entry: TEntry,
options: BuildLiveStreamingEntryOptions,
): TEntry => {
const activeStreamingMessageId = options.activeStreamingMessageId;
if (!activeStreamingMessageId) {
return entry;
}
const livePartsByMessageId = options.livePartsByMessageId;
if (entry.kind === 'ungrouped') {
const message = withLiveParts(entry.message, activeStreamingMessageId, options.liveParts);
const message = withLiveParts(entry.message, livePartsByMessageId);
if (message === entry.message) {
return entry;
}
@@ -59,7 +63,7 @@ export const buildLiveStreamingEntry = <TEntry extends StreamingTailEntry>(
let changed = false;
const assistantMessages = entry.turn.assistantMessages.map((message) => {
const next = withLiveParts(message, activeStreamingMessageId, options.liveParts);
const next = withLiveParts(message, livePartsByMessageId);
if (next !== message) {
changed = true;
}
@@ -131,6 +131,9 @@ const layoutCodeLines = (pre: HTMLPreElement): void => {
const code = pre.querySelector<HTMLElement>(':scope > code');
if (!code || code.hasAttribute('data-md-code-lines')) return;
// The real gutter takes over the reserved footprint.
pre.removeAttribute('data-md-gutter-reserved');
const text = code.textContent ?? '';
const hasTrailingNewline = text.endsWith('\n');
const lines = hasTrailingNewline ? text.slice(0, -1).split('\n') : text.split('\n');
@@ -263,7 +266,15 @@ const decorateCodeBlocks = (root: HTMLElement, ctx: DecorateContext): void => {
pre.style.margin = '0';
pre.style.background = 'transparent';
pre.classList.add('min-w-0', 'w-full', 'flex-1');
if (!ctx.deferCodeLineNumberSync) layoutCodeLines(pre);
if (!ctx.deferCodeLineNumberSync) {
layoutCodeLines(pre);
} else {
// Streaming defers the per-line gutter markup, but the gutter's
// horizontal footprint is reserved immediately — otherwise the
// end-of-stream decorate pass shifts every code line right by the
// gutter column and the finished message visibly jumps.
pre.setAttribute('data-md-gutter-reserved', '');
}
body.appendChild(pre);
wrapper.appendChild(header);
wrapper.appendChild(body);
@@ -1,4 +1,5 @@
import MarkdownShikiWorkerUrl from './markdown-shiki.worker.ts?worker&url';
import { isVSCodeRuntime } from '@/stores/utils/vscodeRuntime';
import {
contentFingerprint,
estimateTokenRunsBytes,
@@ -46,6 +47,8 @@ const resultCache = new HighlightResultCache<CachedHighlight>({
const inflight = new Map<string, Promise<CachedHighlight | null>>();
let worker: Worker | undefined;
let workerCreation: Promise<Worker | undefined> | undefined;
let workerObjectUrl: string | undefined;
let nextId = 0;
const pending = new Map<number, PendingResolver>();
// Theme names whose full definition we've already shipped to the live worker, so
@@ -71,31 +74,56 @@ const failAll = (): void => {
inflight.clear();
worker?.terminate();
worker = undefined;
workerCreation = undefined;
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
};
const getWorker = (): Worker | undefined => {
if (worker) return worker;
const createWorker = async (): Promise<Worker | undefined> => {
if (typeof window === 'undefined' || typeof Worker === 'undefined') return undefined;
try {
worker = new Worker(MarkdownShikiWorkerUrl, { type: 'module' });
let workerUrl = MarkdownShikiWorkerUrl;
if (isVSCodeRuntime(null)) {
const response = await fetch(workerUrl);
if (!response.ok) throw new Error(`Shiki worker request failed with ${response.status}`);
workerObjectUrl = URL.createObjectURL(await response.blob());
workerUrl = workerObjectUrl;
}
const instance = new Worker(workerUrl, { type: 'module' });
worker = instance;
instance.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
instance.onerror = failAll;
instance.onmessageerror = failAll;
instance.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return instance;
} catch (err) {
if (workerObjectUrl) {
URL.revokeObjectURL(workerObjectUrl);
workerObjectUrl = undefined;
}
console.error('Failed to create Shiki worker:', err);
return undefined;
}
worker.onmessage = (event: MessageEvent<MarkdownWorkerResponse>) => {
const resolve = pending.get(event.data.id);
if (!resolve) return;
pending.delete(event.data.id);
resolve(event.data);
};
worker.onerror = failAll;
worker.onmessageerror = failAll;
worker.postMessage({ type: 'init' } satisfies MarkdownWorkerRequest);
return worker;
};
const request = (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = getWorker();
const getWorker = async (): Promise<Worker | undefined> => {
if (worker) return worker;
workerCreation ??= createWorker().finally(() => {
workerCreation = undefined;
});
return workerCreation;
};
const request = async (payload: (id: number) => MarkdownWorkerRequest): Promise<MarkdownWorkerResponse | null> => {
const instance = await getWorker();
if (!instance) return Promise.resolve(null);
const id = ++nextId;
return new Promise<MarkdownWorkerResponse | null>((resolve) => {
@@ -178,9 +178,11 @@ type MarkdownBlock = {
raw: string;
src: string;
mode: 'full' | 'live';
// When false, skip syntax highlighting for this block. Set for the actively
// streaming open code fence so we don't re-tokenize a growing block ~40x/sec
// (O(n^2)); it highlights once the fence closes and becomes a stable block.
// When false, skip syntax highlighting for this block. Block-level commit
// feeds the open fence whole lines at the throttle cadence (<=10/sec), so a
// partial fence highlights too and streamed code arrives colored; only a
// very large open fence falls back to plain text until it closes, keeping
// the repeated worker re-tokenization bounded.
highlight: boolean;
};
@@ -201,6 +203,11 @@ const hasOpenFence = (raw: string): boolean => {
return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last);
};
// Above this, re-highlighting the still-open fence on every committed line
// costs more than the colored preview is worth; the block highlights in one
// pass when the fence closes.
const OPEN_FENCE_HIGHLIGHT_LINE_LIMIT = 300;
const heal = (text: string): string => {
try {
return remend(text, { linkMode: 'text-only' });
@@ -250,11 +257,13 @@ const streamBlocks = (text: string, live: boolean): MarkdownBlock[] => {
const raw = token.raw ?? '';
const isLast = i === tail;
const openFence = token.type === 'code' && hasOpenFence(raw);
const openFenceHighlight = openFence
&& raw.split('\n').length <= OPEN_FENCE_HIGHLIGHT_LINE_LIMIT;
blocks.push({
raw,
src: openFence ? raw : heal(raw),
mode: isLast ? 'live' : 'full',
highlight: !openFence,
highlight: !openFence || openFenceHighlight,
});
}
@@ -19,7 +19,6 @@ import { SaveProjectPlanDialog } from '@/components/session/SaveProjectPlanDialo
import { ForkSessionDialog, type ForkSessionExecution } from '@/components/session/ForkSessionDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { MarkdownImageGallery, SimpleMarkdownRenderer } from '../MarkdownRenderer';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -419,13 +418,10 @@ interface MessageBodyProps {
onShowPopup: (content: ToolPopupContent) => void;
streamPhase: StreamPhase;
allowAnimation: boolean;
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
shouldShowHeader?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void | boolean | Promise<void | boolean>;
copiedMessage?: boolean;
onAuxiliaryContentComplete?: () => void;
showReasoningTraces?: boolean;
agentMention?: AgentMentionInfo;
turnGroupingContext?: TurnGroupingContext;
@@ -1112,10 +1108,8 @@ const AssistantMessageBody = React.memo(({
onShowPopup,
streamPhase: _streamPhase,
allowAnimation: _allowAnimation,
onContentChange,
hasTextContent = false,
onCopyMessage,
onAuxiliaryContentComplete,
showReasoningTraces = false,
turnGroupingContext,
errorMessage,
@@ -1423,50 +1417,6 @@ const AssistantMessageBody = React.memo(({
|| (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized));
const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning;
const hasAuxiliaryContent = hasTools || reasoningParts.length > 0;
const isTextlessAssistantMessage = assistantTextParts.length === 0;
const auxiliaryContentComplete = hasAuxiliaryContent && isTextlessAssistantMessage && !shouldHoldTools && !shouldHoldReasoning && allToolsFinalized && reasoningComplete;
const auxiliaryCompletionAnnouncedRef = React.useRef(false);
const soloReasoningScrollTriggeredRef = React.useRef(false);
React.useEffect(() => {
soloReasoningScrollTriggeredRef.current = false;
}, [messageId]);
React.useEffect(() => {
if (!auxiliaryContentComplete) {
auxiliaryCompletionAnnouncedRef.current = false;
return;
}
if (auxiliaryCompletionAnnouncedRef.current) {
return;
}
auxiliaryCompletionAnnouncedRef.current = true;
onAuxiliaryContentComplete?.();
}, [auxiliaryContentComplete, onAuxiliaryContentComplete]);
React.useEffect(() => {
if (awaitingMessageCompletion) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (hasTools) {
soloReasoningScrollTriggeredRef.current = false;
return;
}
if (reasoningParts.length === 0) {
return;
}
if (shouldHoldReasoning || !reasoningComplete) {
return;
}
if (soloReasoningScrollTriggeredRef.current) {
return;
}
soloReasoningScrollTriggeredRef.current = true;
onContentChange?.('structural');
}, [awaitingMessageCompletion, hasTools, onContentChange, reasoningComplete, reasoningParts.length, shouldHoldReasoning]);
const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion;
const handleForkClick = React.useCallback(
@@ -1821,7 +1771,6 @@ const AssistantMessageBody = React.memo(({
expandedTools={expandedTools}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
streamPhase={effectiveStreamPhase}
showHeader={true}
animateRows={animateActivityRows}
@@ -1898,7 +1847,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
</div>
@@ -1933,7 +1881,6 @@ const AssistantMessageBody = React.memo(({
messageId={messageId}
streamPhase={effectiveStreamPhase}
chatRenderMode={chatRenderMode}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
/>
);
@@ -1945,7 +1892,6 @@ const AssistantMessageBody = React.memo(({
part={part}
messageId={messageId}
streamPhase={effectiveStreamPhase}
onContentChange={onContentChange}
/>
);
}
@@ -1989,7 +1935,6 @@ const AssistantMessageBody = React.memo(({
onToggle={onToggleTool}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
/>
@@ -2061,7 +2006,6 @@ const AssistantMessageBody = React.memo(({
messageActionButtons,
renderJustificationActions,
sessionId,
onContentChange,
onShowPopup,
onToggleTool,
shouldRenderActivityGroup,
@@ -2,7 +2,6 @@ import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import type { StreamPhase, ToolPopupContent } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { resolveAssistantDisplayText, shouldRenderAssistantText } from './assistantTextVisibility';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
@@ -17,7 +16,6 @@ interface AssistantTextPartProps {
messageId: string;
streamPhase: StreamPhase;
chatRenderMode?: 'sorted' | 'live';
onContentChange?: (reason?: ContentChangeReason, messageId?: string) => void;
onShowPopup?: (content: ToolPopupContent) => void;
}
@@ -1,6 +1,5 @@
import React from 'react';
import type { Part } from '@opencode-ai/sdk/v2';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { useUIStore } from '@/stores/useUIStore';
import { ReasoningTimelineBlock } from './ReasoningPart';
@@ -22,14 +21,12 @@ const cleanJustificationText = (text: string): string => {
interface JustificationBlockProps {
part: Part;
messageId: string;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}
const JustificationBlock: React.FC<JustificationBlockProps> = ({
part,
messageId,
onContentChange,
actions,
}) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
@@ -47,7 +44,6 @@ const JustificationBlock: React.FC<JustificationBlockProps> = ({
<ReasoningTimelineBlock
text={textContent}
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`}
time={time}
showDuration={chatRenderMode !== 'sorted'}
@@ -4,7 +4,6 @@ import { cn } from '@/lib/utils';
import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types';
import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2';
import type { StreamPhase } from '../types';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import ToolPart from './ToolPart';
import { MinDurationShineText } from './MinDurationShineText';
@@ -40,7 +39,6 @@ interface ProgressiveGroupProps {
expandedTools: Set<string>;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
showHeader: boolean;
animateRows?: boolean;
@@ -376,9 +374,7 @@ interface ExpandableToolRowProps {
isMobile: boolean;
onToggleTool: (toolId: string) => void;
onShowPopup: (content: ToolPopupContent) => void;
onContentChange?: (reason?: ContentChangeReason) => void;
animateTailText: boolean;
animateRows: boolean;
}
const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
@@ -387,9 +383,7 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isMobile,
onToggleTool,
onShowPopup,
onContentChange,
animateTailText,
animateRows,
}) => {
const handleToggle = React.useCallback(() => {
onToggleTool(activity.id);
@@ -401,23 +395,22 @@ const ExpandableToolRow: React.FC<ExpandableToolRowProps> = ({
isExpanded={isExpanded}
onToggle={handleToggle}
isMobile={isMobile}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animateTailText}
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
// Wrappers are unconditional: a conditional wrapper changes the element
// type at this position when animateTailText/animateRows flip (message
// completion), remounting the tool subtree and replaying the reveal wipe.
// Both wrappers are inert with animation off.
return (
<FadeInOnReveal>
<ToolRevealOnMount animate={animateTailText} wipe>
{content}
</ToolRevealOnMount>
</FadeInOnReveal>
);
};
const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
@@ -425,9 +418,7 @@ const MemoExpandableToolRow = React.memo(ExpandableToolRow, (prev, next) => {
&& prev.isMobile === next.isMobile
&& prev.onToggleTool === next.onToggleTool
&& prev.onShowPopup === next.onShowPopup
&& prev.onContentChange === next.onContentChange
&& prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& prev.activity.id === next.activity.id
&& prev.activity.kind === next.activity.kind
&& prev.activity.endedAt === next.activity.endedAt
@@ -438,14 +429,12 @@ interface StaticGroupedToolRowProps {
toolName: string;
activities: TurnActivityPart[];
animateTailText: boolean;
animateRows: boolean;
}
const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
toolName,
activities,
animateTailText,
animateRows,
}) => {
const content = (
<StaticToolRow
@@ -455,23 +444,22 @@ const StaticGroupedToolRow: React.FC<StaticGroupedToolRowProps> = ({
/>
);
const maybeWrapped = animateTailText ? (
<ToolRevealOnMount animate={true} wipe>
{content}
</ToolRevealOnMount>
) : content;
if (!animateRows) {
return maybeWrapped;
}
return <FadeInOnReveal>{maybeWrapped}</FadeInOnReveal>;
// Wrappers are unconditional: a conditional wrapper changes the element
// type at this position when animateTailText/animateRows flip (message
// completion), remounting the tool subtree and replaying the reveal wipe.
// Both wrappers are inert with animation off.
return (
<FadeInOnReveal>
<ToolRevealOnMount animate={animateTailText} wipe>
{content}
</ToolRevealOnMount>
</FadeInOnReveal>
);
};
const MemoStaticGroupedToolRow = React.memo(StaticGroupedToolRow, (prev, next) => {
return prev.toolName === next.toolName
&& prev.animateTailText === next.animateTailText
&& prev.animateRows === next.animateRows
&& areActivityListsEqual(prev.activities, next.activities);
});
@@ -795,9 +783,8 @@ export const StaticToolRow = React.memo(StaticToolRowInner, (prev, next) => {
/**
* Inline reasoning text block rendered as dimmed italic markdown.
*/
const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhase }: {
const InlineReasoningBlock = React.memo(({ activity, streamPhase }: {
activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
streamPhase: StreamPhase;
}) => {
return (
@@ -805,7 +792,6 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
part={activity.part}
messageId={activity.messageId}
streamPhase={streamPhase}
onContentChange={onContentChange}
/>
);
});
@@ -813,16 +799,14 @@ const InlineReasoningBlock = React.memo(({ activity, onContentChange, streamPhas
/**
* Inline justification text block rendered as normal assistant text between tools.
*/
const InlineJustificationBlock = React.memo(({ activity, onContentChange, actions }: {
const InlineJustificationBlock = React.memo(({ activity, actions }: {
activity: TurnActivityPart;
onContentChange?: (reason?: ContentChangeReason) => void;
actions?: React.ReactNode;
}) => {
return (
<JustificationBlock
part={activity.part}
messageId={activity.messageId}
onContentChange={onContentChange}
actions={actions}
/>
);
@@ -837,7 +821,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
expandedTools,
onToggleTool,
onShowPopup,
onContentChange,
streamPhase,
showHeader,
animateRows = true,
@@ -898,7 +881,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<InlineReasoningBlock
activity={row.activity}
streamPhase={streamPhase}
onContentChange={onContentChange}
/>
</>
);
@@ -909,7 +891,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
<>
<InlineJustificationBlock
activity={row.activity}
onContentChange={onContentChange}
actions={renderJustificationActions?.(row.activity)}
/>
</>
@@ -924,9 +905,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
@@ -937,7 +916,6 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
toolName={row.toolName}
activities={row.activities}
animateTailText={row.activities.some((activity) => animatedToolIds?.has(activity.id))}
animateRows={animateRows}
/>
);
@@ -950,9 +928,7 @@ const ProgressiveGroup: React.FC<ProgressiveGroupProps> = ({
isMobile={isMobile}
onToggleTool={onToggleTool}
onShowPopup={onShowPopup}
onContentChange={onContentChange}
animateTailText={Boolean(animatedToolIds?.has(row.activity.id))}
animateRows={animateRows}
/>
);
@@ -2,7 +2,6 @@ import React from 'react';
import { animate, type AnimationPlaybackControls } from 'motion';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Icon } from '@/components/icon/Icon';
import { BusyDots } from './BusyDots';
@@ -10,6 +9,7 @@ import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { MarkdownRenderer } from '../../MarkdownRenderer';
import { useStreamingTextThrottle } from '../../hooks/useStreamingTextThrottle';
import { commitStreamedText } from '../../lib/streamTextCommit';
import type { StreamPhase } from '../types';
const TOOL_ROW_TEXT_CLASS = '!text-[length:var(--text-meta)] !leading-5 sm:!leading-6 tracking-normal';
@@ -81,7 +81,6 @@ const getReasoningSummary = (text: string): string => {
type ReasoningTimelineBlockProps = {
text: string;
variant: ReasoningVariant;
onContentChange?: (reason?: ContentChangeReason) => void;
blockId: string;
time?: { start?: number; end?: number };
showDuration?: boolean;
@@ -99,7 +98,6 @@ type ExpansionState = {
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
text,
variant,
onContentChange,
blockId,
time,
isStreaming = false,
@@ -123,11 +121,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const contentRef = React.useRef<HTMLDivElement>(null);
const contentAnimationRef = React.useRef<AnimationPlaybackControls | null>(null);
const contentMountedRef = React.useRef(false);
// Stable handle to onContentChange so the height-animation layout effect can
// signal auto-follow without taking onContentChange as a dependency (which
// would risk re-running — and thus restarting — the animation on re-render).
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const summary = React.useMemo(() => getReasoningSummary(text), [text]);
const toggleAriaLabel = isExpanded
@@ -137,8 +130,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
const handleToggle = React.useCallback(() => {
setShouldRenderExpandedContent(true);
setExpansion({ expanded: !isExpanded, source: 'user' });
onContentChange?.('structural');
}, [isExpanded, onContentChange]);
}, [isExpanded]);
const handleKeyDown = React.useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -159,13 +151,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
});
}, [canAutoExpand]);
React.useEffect(() => {
if (text.trim().length === 0) {
return;
}
onContentChange?.('structural');
}, [onContentChange, text]);
React.useEffect(() => {
if (isExpanded || isStreaming) {
setShouldRenderExpandedContent(true);
@@ -239,11 +224,6 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
element.style.height = '0px';
} else {
element.style.height = `${element.scrollHeight}px`;
// Only the COLLAPSE animation needs the guard: it shrinks the
// timeline and the trailing async scroll events can be misread as a
// user scroll-away. Expansion grows the timeline and re-pins cleanly,
// and guarding it caused a faint scroll fight while thinking streams.
onContentChangeRef.current?.('animation');
}
const animation = animate(
@@ -436,14 +416,12 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
type ReasoningPartProps = {
part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string;
streamPhase?: StreamPhase;
};
const ReasoningPart = React.memo(({
part,
onContentChange,
messageId,
streamPhase,
}: ReasoningPartProps) => {
@@ -454,11 +432,14 @@ const ReasoningPart = React.memo(({
const time = partWithText.time;
const canBeStreaming = streamPhase === undefined || streamPhase !== 'completed';
const isStreaming = chatRenderMode === 'live' && canBeStreaming && typeof time?.end !== 'number';
const throttledText = useStreamingTextThrottle({
const throttledTextRaw = useStreamingTextThrottle({
text: textContent,
isStreaming,
identityKey: `${messageId}:${part.id ?? 'reasoning'}`,
});
// Same block-level reveal as assistant text: a shown reasoning paragraph
// never mutates in place.
const throttledText = isStreaming ? commitStreamedText(throttledTextRaw) : throttledTextRaw;
// Show reasoning even if time.end isn't set yet (during streaming)
// Only hide if there's no text content
@@ -470,7 +451,6 @@ const ReasoningPart = React.memo(({
<ReasoningTimelineBlock
text={throttledText}
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
time={time}
isStreaming={isStreaming}
@@ -20,7 +20,6 @@ import { toast } from '@/components/ui';
import { Text } from '@/components/ui/text';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { copyTextToClipboard } from '@/lib/clipboard';
import type { ContentChangeReason } from '@/hooks/useChatAutoFollow';
import type { ToolPopupContent } from '../types';
import { PlainDiffFallback } from './PlainDiffFallback';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
@@ -82,7 +81,6 @@ interface ToolPartProps {
onToggle: (toolId: string) => void;
isMobile: boolean;
alwaysShowActions?: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void;
animateTailText?: boolean;
}
@@ -1684,7 +1682,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
isExpanded,
onToggle,
isMobile,
onContentChange,
onShowPopup,
animateTailText = true,
}) => {
@@ -1754,10 +1751,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
});
}, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]);
const shouldNotifyStructuralChange = isFinalized || isTaskTool;
const onContentChangeRef = React.useRef(onContentChange);
onContentChangeRef.current = onContentChange;
const expandedContentRef = React.useRef<HTMLDivElement>(null);
React.useLayoutEffect(() => {
@@ -1772,11 +1765,7 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
element.style.height = isExpanded ? 'auto' : '0px';
element.style.overflow = isExpanded ? 'visible' : 'hidden';
if (shouldNotifyStructuralChange) {
onContentChangeRef.current?.('structural');
}
}, [isExpanded, isTaskTool, shouldNotifyStructuralChange]);
}, [isExpanded, isTaskTool]);
const partMetadata = (part as unknown as { metadata?: unknown }).metadata;
const time = stateWithData.time;
@@ -1934,26 +1923,6 @@ const ToolPartContent: React.FC<ToolPartProps> = ({
}
return metadataTaskSummaryEntries;
}, [childSessionTaskSummaryEntries, metadataTaskSummaryEntries]);
const taskSummaryRenderSignature = React.useMemo(() => {
return taskSummaryEntries.map(getTaskSummaryEntryRenderSignature).join('\u0000');
}, [taskSummaryEntries]);
const lastTaskSummaryRenderSignatureRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!isTaskTool) {
lastTaskSummaryRenderSignatureRef.current = null;
return;
}
const previous = lastTaskSummaryRenderSignatureRef.current;
lastTaskSummaryRenderSignatureRef.current = taskSummaryRenderSignature;
if (previous === null || previous === taskSummaryRenderSignature || taskSummaryEntries.length === 0) {
return;
}
onContentChangeRef.current?.('structural');
}, [isTaskTool, taskSummaryEntries.length, taskSummaryRenderSignature]);
const diffStats = React.useMemo(() => {
return (normalizedPartTool === 'edit' || normalizedPartTool === 'multiedit' || normalizedPartTool === 'apply_patch')
? parseDiffStats(metadata)
@@ -2351,7 +2320,6 @@ export default React.memo(ToolPart, (prev, next) => {
&& prev.isExpanded === next.isExpanded
&& prev.isMobile === next.isMobile
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.onContentChange === next.onContentChange
&& prev.onShowPopup === next.onShowPopup
&& prev.animateTailText === next.animateTailText;
});
@@ -229,12 +229,11 @@ export function WorkingPlaceholder({
return (
<div
// Styled to mirror the turn footer's model row (text-sm,
// muted-foreground/60, no left inset): when the turn completes this row
// disappears and the footer appears in the same visual spot, so the two
// must read as the same line swapping its text.
// Full muted-foreground, matching the scroll-to-bottom pill's status
// text: the row and the pill hand off to each other in the same spot
// and must read as one element changing chrome.
className={
'flex h-full items-center text-muted-foreground/60'
'flex h-full items-center text-muted-foreground'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
@@ -1,9 +1,16 @@
import { commitStreamedText } from '../../lib/streamTextCommit';
export const resolveAssistantDisplayText = (input: {
textContent: string;
throttledTextContent: string;
isStreaming: boolean;
}): string => {
return input.isStreaming ? input.throttledTextContent : input.textContent;
// While streaming, reveal whole blocks only: rendering stops at the last
// complete line so a shown paragraph never mutates in place. The held
// tail lands with the next line break (or the finalize pass).
return input.isStreaming
? commitStreamedText(input.throttledTextContent)
: input.textContent;
};
export const shouldRenderAssistantText = (input: {
File diff suppressed because it is too large Load Diff
+70 -401
View File
@@ -1,10 +1,8 @@
import React, { useRef, useEffect } from 'react';
import { animate, motion, useMotionValue } from 'motion/react';
import React from 'react';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
import { SidebarTopBar } from './SidebarTopBar';
import { TitlebarLeftControls } from './TitlebarLeftControls';
import { ProjectContextPanel } from './RightSidebarTabs';
import { ContextPanel } from './ContextPanel';
import { ContextPanelRail } from './ContextPanelRail';
import { ErrorBoundary } from '../ui/ErrorBoundary';
@@ -18,8 +16,6 @@ import { ArchiveView } from '@/components/views/ArchiveView';
import { WorktreesView } from '@/components/views/WorktreesView';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
import { MultiRunLauncher } from '@/components/multirun';
import { TerminalView } from '@/components/views/TerminalView';
import { DrawerProvider } from '@/contexts/DrawerContext';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -30,24 +26,17 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { ChatView } from '@/components/views/ChatView';
// Keep TerminalView eager: the bottom dock reserves its height immediately, so
// suspending here leaves a large blank panel on slower machines.
// Other heavy views stay on-demand to reduce initial bundle parse time:
// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the
// startup graph when imported statically.
const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
/**
* Desktop-surface layout: the chat owns the main area, and every other
* surface (git, diff, files, terminal, ...) opens in the ContextPanel via the
* rail. Phone-sized viewports run the separate MobileApp shell a viewport
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
*/
export const MainLayout: React.FC = () => {
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const activeSurface = useUIStore((state) => state.activeSurface);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
// Mount the windowed settings dialog only after its first open: rendering
@@ -67,10 +56,9 @@ export const MainLayout: React.FC = () => {
const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen);
const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId);
// Any full-page surface replacing the chat area. While open, the chat and
// secondary views are fully hidden (not just covered) so none of their
// floating chrome bleeds through, and selecting a session / draft / main
// tab anywhere closes the surface.
// Any full-page surface replacing the chat area. While open, the chat is
// fully hidden (not just covered) so none of its floating chrome bleeds
// through, and selecting a session or draft anywhere closes the surface.
const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen;
React.useEffect(() => {
@@ -82,166 +70,11 @@ export const MainLayout: React.FC = () => {
const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft;
if (sessionSelected || draftOpened) closeSurfacePages();
});
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
if (state.activeSurface !== prev.activeSurface) closeSurfacePages();
});
return () => {
unsubscribeSession();
unsubscribeTab();
};
}, []);
const { isMobile } = useDeviceInfo();
const mobilePanelsResetRef = React.useRef(false);
// Mobile drawer state
const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false);
const [mobileRightSidebarOpen, setMobileRightSidebarOpen] = React.useState(false);
const [mobileLeftDrawerVisible, setMobileLeftDrawerVisible] = React.useState(false);
const [mobileRightDrawerVisible, setMobileRightDrawerVisible] = React.useState(false);
const setMobileSessionPanelOpen = React.useCallback((open: boolean) => {
setMobileLeftDrawerOpen(open);
useUIStore.getState().setSessionSwitcherOpen(open);
}, []);
const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth);
// Left drawer motion value
const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current);
const leftDrawerWidth = useRef(0);
// Right drawer motion value
const rightDrawerX = useMotionValue(initialDrawerWidthRef.current);
const rightDrawerWidth = useRef(0);
// Compute drawer width
useEffect(() => {
if (isMobile) {
leftDrawerWidth.current = window.innerWidth;
rightDrawerWidth.current = window.innerWidth;
}
}, [isMobile]);
// Sync left drawer state and motion value
useEffect(() => {
if (!isMobile) {
setMobileLeftDrawerVisible(false);
return;
}
if (mobileLeftDrawerOpen) {
setMobileLeftDrawerVisible(true);
}
animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, {
type: 'spring',
stiffness: 400,
damping: 35,
mass: 0.8,
});
}, [mobileLeftDrawerOpen, isMobile, leftDrawerX]);
// Sync right drawer state and motion value
useEffect(() => {
if (!isMobile) {
setMobileRightDrawerVisible(false);
return;
}
if (mobileRightSidebarOpen) {
setMobileRightDrawerVisible(true);
}
animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, {
type: 'spring',
stiffness: 400,
damping: 35,
mass: 0.8,
});
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
useEffect(() => {
if (!isMobile) return;
return leftDrawerX.on('change', (value) => {
const width = leftDrawerWidth.current || initialDrawerWidthRef.current;
const visible = mobileLeftDrawerOpen || value > -width + 0.5;
setMobileLeftDrawerVisible((previous) => previous === visible ? previous : visible);
});
}, [isMobile, leftDrawerX, mobileLeftDrawerOpen]);
useEffect(() => {
if (!isMobile) return;
return rightDrawerX.on('change', (value) => {
const width = rightDrawerWidth.current || initialDrawerWidthRef.current;
const visible = mobileRightSidebarOpen || value < width - 0.5;
setMobileRightDrawerVisible((previous) => previous === visible ? previous : visible);
});
}, [isMobile, mobileRightSidebarOpen, rightDrawerX]);
// Sync session switcher close events to left drawer.
useEffect(() => {
if (isMobile && !isSessionSwitcherOpen && mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
}
}, [isSessionSwitcherOpen, isMobile, mobileLeftDrawerOpen, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile) {
mobilePanelsResetRef.current = false;
return;
}
if (mobilePanelsResetRef.current) {
return;
}
mobilePanelsResetRef.current = true;
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
}, [isMobile, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
return;
}
let disposed = false;
let timeoutId: number | undefined;
const scheduleDraftOpen = (delayMs: number) => {
timeoutId = window.setTimeout(() => {
if (disposed) {
return;
}
const sessionState = useSessionUIStore.getState();
const uiState = useUIStore.getState();
if (uiState.activeMainTab !== 'chat' || uiState.isSettingsDialogOpen || sessionState.currentSessionId || sessionState.newSessionDraft?.open) {
return;
}
if (sessionState.isLoading) {
scheduleDraftOpen(250);
return;
}
sessionState.openNewSessionDraft({ automatic: true });
}, delayMs);
};
scheduleDraftOpen(500);
return () => {
disposed = true;
if (timeoutId !== undefined) {
window.clearTimeout(timeoutId);
}
};
}, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
// Ensure mobile drawers are closed when opening full-screen settings
useEffect(() => {
if (!isMobile || !isSettingsDialogOpen) {
return;
}
setMobileSessionPanelOpen(false);
setMobileRightSidebarOpen(false);
}, [isMobile, isSettingsDialogOpen, setMobileSessionPanelOpen]);
useUpdatePolling();
@@ -252,247 +85,83 @@ export const MainLayout: React.FC = () => {
}
}, [isMobile, setIsMobile]);
const handleToggleMobileRightDrawer = React.useCallback(() => {
if (mobileLeftDrawerOpen) {
setMobileSessionPanelOpen(false);
}
setMobileRightSidebarOpen(!mobileRightSidebarOpen);
}, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]);
const secondaryView = React.useMemo(() => {
// Desktop surfaces live in the context panel; the only full-view
// overlays left there are the terminal (promoted by project actions)
// and the diagram viewer. Mobile keeps the full tab set.
if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') {
return null;
}
switch (activeSurface) {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
case 'git':
return <React.Suspense fallback={null}><GitView isActive={!mobileRightSidebarOpen} /></React.Suspense>;
case 'diff':
return <React.Suspense fallback={null}><DiffView /></React.Suspense>;
case 'terminal':
return <TerminalView />;
case 'files':
return <React.Suspense fallback={null}><FilesView /></React.Suspense>;
case 'context':
return <React.Suspense fallback={null}><ProjectContextPanel /></React.Suspense>;
case 'diagram':
return <React.Suspense fallback={null}><DiagramView /></React.Suspense>;
default:
return null;
}
}, [activeSurface, isMobile, mobileRightSidebarOpen]);
const isChatActive = activeSurface === 'chat';
return (
<DiffWorkerProvider>
<div
data-page-scroll-lock="true"
className={cn(
'main-content-safe-area',
isMobile ? 'flex h-[100dvh] flex-col' : 'relative flex h-[100dvh]',
'bg-background'
)}
className="main-content-safe-area relative flex h-[100dvh] bg-background"
>
<CommandPalette />
<HelpDialog />
<OpenCodeStatusDialog />
<SessionDialogs />
{isMobile ? (
<DrawerProvider value={{
leftDrawerOpen: mobileLeftDrawerOpen,
rightDrawerOpen: mobileRightSidebarOpen,
toggleLeftDrawer: () => {
const nextOpen = !mobileLeftDrawerOpen;
if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
}
setMobileSessionPanelOpen(nextOpen);
},
toggleRightDrawer: handleToggleMobileRightDrawer,
leftDrawerX,
rightDrawerX,
leftDrawerWidth,
rightDrawerWidth,
setMobileLeftDrawerOpen: setMobileSessionPanelOpen,
setRightSidebarOpen: setMobileRightSidebarOpen,
}}>
{/* Mobile: header + drawer mode */}
{!isSettingsDialogOpen && <Header
onToggleLeftDrawer={() => {
const nextOpen = !mobileLeftDrawerOpen;
if (mobileRightSidebarOpen) {
setMobileRightSidebarOpen(false);
}
setMobileSessionPanelOpen(nextOpen);
}}
onToggleRightDrawer={() => {
handleToggleMobileRightDrawer();
}}
leftDrawerOpen={mobileLeftDrawerOpen}
rightDrawerOpen={mobileRightSidebarOpen}
/>}
{/* Main content area (fixed) */}
<div
data-page-scroll-lock="true"
className={cn(
'flex flex-1 overflow-hidden relative',
isSettingsDialogOpen && 'hidden'
)}
{/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */}
<TitlebarLeftControls />
{/* Full-height Sidebar beside [Header above (chat | RightSidebar)] */}
<div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-border"
topBar={<SidebarTopBar />}
>
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div>
{secondaryView && (
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
<MultiRunLauncher
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
{/* Always mount SessionSidebar on mobile to match desktop behavior.
Conditional mount (mobileLeftDrawerVisible && ...) caused a
data-loading cascade on every drawer open: paginated sessions
fetch, worktree discovery, repo status, PR status, and 10+ memo
recomputations. On Android PWA this manifested as a >10s delay
before the drawer became interactive (issue #1695). Visibility is
controlled by the leftDrawerX transform (off-screen when closed).
The invisible class matters when fully hidden: leftDrawerWidth is
not recomputed on resize/rotation, so a closed drawer translated by
the old width could otherwise peek into the viewport; it also keeps
the off-screen sidebar out of the tab order and skips painting it. */}
<motion.div
className={cn(
'absolute inset-0 z-20 bg-sidebar',
!mobileLeftDrawerVisible && 'pointer-events-none invisible',
)}
data-page-scroll-lock="true"
style={{ x: leftDrawerX }}
aria-hidden={!mobileLeftDrawerOpen}
>
<ErrorBoundary>
<SessionSidebar mobileVariant isVisible={mobileLeftDrawerVisible} />
</ErrorBoundary>
</motion.div>
{mobileRightDrawerVisible && (
<motion.div className="absolute inset-0 z-20 bg-sidebar" data-page-scroll-lock="true" style={{ x: rightDrawerX }} aria-hidden={!mobileRightSidebarOpen}>
<ErrorBoundary>
<React.Suspense fallback={null}><GitView isActive={mobileRightSidebarOpen} /></React.Suspense>
</ErrorBoundary>
</motion.div>
)}
</main>
</div>
{/* Mobile settings: full screen */}
{isSettingsDialogOpen && (
<div
className="absolute inset-0 z-10 bg-background"
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
>
<ErrorBoundary>
<React.Suspense fallback={null}>
<SettingsView onClose={() => setSettingsDialogOpen(false)} />
</React.Suspense>
</ErrorBoundary>
</div>
)}
</DrawerProvider>
) : (
<>
{/* Persistent top-left controls (toggle + project actions) that
stay put while the sidebar/header animate beneath them. */}
<TitlebarLeftControls />
{/* Desktop: full-height Sidebar beside [Header above (chat | RightSidebar)] */}
<div className="flex flex-1 overflow-hidden" data-page-scroll-lock="true">
<Sidebar
isOpen={isSidebarOpen}
isMobile={isMobile}
className="border-border"
topBar={<SidebarTopBar />}
>
<SessionSidebar isVisible={isSidebarOpen} />
</Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
{/* Holds the chat and the context panel together, so its
width does not move when the context panel opens. The
work-status panel measures this rather than the chat,
which the context panel animates. */}
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
<SessionSidebar isVisible={isSidebarOpen} />
</Sidebar>
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden bg-background" data-page-scroll-lock="true">
<Header />
<div className="relative flex flex-1 min-h-0 overflow-hidden bg-background" data-page-scroll-lock="true">
<div className="relative flex flex-1 min-w-0 flex-col overflow-hidden border-t border-border bg-background" data-page-scroll-lock="true">
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
{/* Holds the chat and the context panel together, so its
width does not move when the context panel opens. The
work-status panel measures this rather than the chat,
which the context panel animates. */}
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true" data-chat-area="true">
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary><ChatView active={!isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
</div>
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
{/* isWindowed: the app Header already shows the surface
title, so skip the launcher's own title bar. */}
<MultiRunLauncher
isWindowed
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
{secondaryView && (
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
<ErrorBoundary>{secondaryView}</ErrorBoundary>
</div>
)}
{isMultiRunLauncherOpen && (
<div className="absolute inset-0 z-10 bg-background">
<ErrorBoundary>
{/* isWindowed: the app Header already shows the surface
title, so skip the launcher's own title bar. */}
<MultiRunLauncher
isWindowed
initialPrompt={multiRunLauncherPrefillPrompt}
onCreated={() => setMultiRunLauncherOpen(false)}
onCancel={() => setMultiRunLauncherOpen(false)}
/>
</ErrorBoundary>
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
</main>
<ContextPanel />
</div>
)}
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
<ErrorBoundary><ArchiveView /></ErrorBoundary>
<ErrorBoundary><WorktreesView /></ErrorBoundary>
</main>
<ContextPanel />
</div>
</div>
<div className="border-t border-border" data-page-scroll-lock="true">
<ErrorBoundary><ContextPanelRail /></ErrorBoundary>
</div>
</div>
<div className="border-t border-border" data-page-scroll-lock="true">
<ErrorBoundary><ContextPanelRail /></ErrorBoundary>
</div>
</div>
</div>
</div>
{/* Desktop settings: windowed dialog with blur */}
{settingsWindowMounted ? (
<React.Suspense fallback={null}>
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
</React.Suspense>
) : null}
</>
)}
</div>
</DiffWorkerProvider>
{/* Settings: windowed dialog with blur */}
{settingsWindowMounted ? (
<React.Suspense fallback={null}>
<SettingsWindow
open={isSettingsDialogOpen}
onOpenChange={setSettingsDialogOpen}
/>
</React.Suspense>
) : null}
</div>
</DiffWorkerProvider>
);
};
@@ -0,0 +1,426 @@
import React from 'react';
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { ContextMenu } from '@base-ui/react/context-menu';
import type { Session } from '@opencode-ai/sdk/v2';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass } from '@/components/ui/dropdown-menu.styles';
import { Icon } from '@/components/icon/Icon';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { useSessionTabsStore } from '@/stores/useSessionTabsStore';
import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs';
import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGlobalSessionStatus } from '@/sync/sync-context';
import { useSessionUnseenCount } from '@/sync/notification-store';
const restrictToXAxis: Modifier = ({ transform }) => ({ ...transform, y: 0 });
type SessionTab = { id: string; session: Session };
export type SessionTabMenuComponents = {
Item: React.ComponentType<{
className?: string;
disabled?: boolean;
onClick?: React.MouseEventHandler;
children?: React.ReactNode;
}>;
Separator: React.ComponentType<{ className?: string }>;
};
export type SessionTabMenuArgs = {
session: Session;
isActive: boolean;
select: () => void;
closeOtherTabs: () => void;
/** Menu primitives for the surface the menu opens in (dropdown or context menu). */
components: SessionTabMenuComponents;
};
const dropdownComponents: SessionTabMenuComponents = {
Item: DropdownMenuItem,
Separator: DropdownMenuSeparator,
};
const contextComponents: SessionTabMenuComponents = {
Item: ({ className, ...props }) => (
<ContextMenu.Item className={cn(dropdownMenuItemClass, className)} {...props} />
),
Separator: ({ className, ...props }) => (
<ContextMenu.Separator className={cn(dropdownMenuSeparatorClass, className)} {...props} />
),
};
/**
* One tab, active or not. The tab drags to reorder; the menu and close
* controls sit in a hover-revealed overlay at the tab's end (menu first,
* close after it). One session menu supplied by the header via
* `renderMenu` backs both the "..." dropdown and the right-click context
* menu, which opens under the cursor without changing the active tab. The
* dropdown's anchor overlay stays mounted through the close animation so the
* popup never flashes detached. While the active tab is renaming, the
* overlay is suppressed entirely only the rename controls show.
*/
const SessionTabItem: React.FC<{
tab: SessionTab;
isActive: boolean;
suppressControls: boolean;
onSelect: (tab: SessionTab) => void;
onClose: (id: string) => void;
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
closeOtherTabs: (id: string) => void;
onMenuOpenChangeComplete?: (open: boolean) => void;
children?: React.ReactNode;
}> = ({ tab, isActive, suppressControls, onSelect, onClose, renderMenu, closeOtherTabs, onMenuOpenChangeComplete, children }) => {
const { t } = useI18n();
const [menuOpen, setMenuOpen] = React.useState(false);
// Keeps the overlay (the dropdown's anchor) mounted through the close animation.
const [menuVisible, setMenuVisible] = React.useState(false);
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: tab.id });
const title = tab.session.title?.trim() || t('sessions.sidebar.session.untitled');
const overlayVisible = !suppressControls && (menuOpen || menuVisible);
// Session state for the dot and the hover tooltip.
const sessionStatus = useGlobalSessionStatus(tab.id);
const isStreaming = sessionStatus?.type === 'busy' || sessionStatus?.type === 'retry';
const unseenCount = useSessionUnseenCount(tab.id);
const showUnread = unseenCount > 0 && !isActive && !isStreaming;
const showDot = isStreaming || showUnread;
const dotLabel = isStreaming
? t('sessions.sidebar.session.status.active')
: t('sessions.sidebar.session.status.unread');
const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({
session: tab.session,
isActive,
select: () => onSelect(tab),
closeOtherTabs: () => closeOtherTabs(tab.id),
components,
});
return (
<div
ref={setNodeRef}
style={{ transform: DndCSS.Translate.toString(transform), transition }}
className={cn('session-tab-slot flex h-7 w-44 shrink-0 touch-none', isDragging && 'z-10 opacity-60')}
data-active={isActive ? 'true' : 'false'}
{...(isActive ? { 'data-active-session-tab': true } : {})}
{...attributes}
{...listeners}
>
<ContextMenu.Root
open={contextMenuOpen}
onOpenChange={setContextMenuOpen}
onOpenChangeComplete={(open) => onMenuOpenChangeComplete?.(open)}
>
<ContextMenu.Trigger
render={(triggerProps) => (
<div
{...triggerProps}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? undefined : 0}
onClick={isActive ? undefined : () => onSelect(tab)}
onKeyDown={isActive ? undefined : (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onSelect(tab);
}
}}
onAuxClick={(event) => {
if (event.button === 1) {
event.preventDefault();
onClose(tab.id);
}
}}
data-controls-open={overlayVisible ? 'true' : 'false'}
className={cn(
'session-tab group/session-tab relative flex h-7 w-full min-w-0 select-none items-center rounded-md px-2',
'transition-colors duration-75',
isActive
? 'bg-interactive-selection'
: cn(
'cursor-pointer text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
overlayVisible && 'bg-interactive-hover text-foreground',
),
)}
>
<div className={cn(
'flex min-w-0 flex-1 items-center',
!suppressControls && 'group-hover/session-tab:pr-10',
overlayVisible && 'pr-10',
)}
>
<div className={cn(
'min-w-0 flex-1 overflow-hidden whitespace-nowrap',
!suppressControls && 'session-tab-title',
)}
>
{isActive ? children : (
<span className="text-[13px] font-medium leading-4">{title}</span>
)}
</div>
{showDot ? (
<span
className={cn(
'ml-1.5 h-1.5 w-1.5 shrink-0 rounded-full',
isStreaming ? 'bg-primary' : 'bg-[var(--status-info)]',
!suppressControls && 'group-hover/session-tab:opacity-0',
overlayVisible && 'opacity-0',
)}
aria-label={dotLabel}
/>
) : null}
</div>
{!suppressControls ? (
<div
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
className={cn(
'absolute right-1 top-1/2 hidden -translate-y-1/2 items-center gap-0.5',
'opacity-0 transition-opacity duration-150',
'group-hover/session-tab:flex group-hover/session-tab:opacity-100',
overlayVisible && 'flex opacity-100',
)}
>
<DropdownMenu
open={menuOpen}
onOpenChange={(open) => {
setMenuOpen(open);
if (open) setMenuVisible(true);
}}
onOpenChangeComplete={(open) => {
if (!open) setMenuVisible(false);
onMenuOpenChangeComplete?.(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t('header.sessionTabs.tabMenuAria')}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="more" className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-[190px]">
{renderMenu(menuArgsFor(dropdownComponents))}
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
aria-label={t('header.sessionTabs.closeTab')}
onClick={() => onClose(tab.id)}
className="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-foreground"
>
<Icon name="close" className="size-4" />
</button>
</div>
) : null}
</div>
)}
/>
<ContextMenu.Portal>
<ContextMenu.Positioner className="app-region-no-drag z-50">
<ContextMenu.Popup
data-slot="dropdown-menu-content"
style={{ color: 'var(--surface-elevated-foreground)' }}
className={cn(dropdownMenuPopupClass, 'min-w-[190px]')}
>
{renderMenu(menuArgsFor(contextComponents))}
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>
</div>
);
};
/**
* The header's horizontal working set of sessions (web/desktop only).
*
* Every session the user opens joins the strip once; the tab whose session is
* current renders `children` the header's title/rename block inside a
* selected pill. Closing a tab only removes it from the strip; closing the
* active one activates its neighbour. Ids whose session has not loaded (or
* was archived/deleted) stay in the store but do not render, so a partial
* session list never destroys the working set.
*/
export const SessionTabsStrip: React.FC<{
/** Menu items for one tab's session, supplied by the header. */
renderMenu: (args: SessionTabMenuArgs) => React.ReactNode;
/** Fires when a tab menu finishes opening/closing (deferred rename hook). */
onMenuOpenChangeComplete?: (open: boolean) => void;
/** While the active tab renames, its hover controls stay hidden. */
suppressActiveTabControls?: boolean;
children: React.ReactNode;
}> = ({ renderMenu, onMenuOpenChangeComplete, suppressActiveTabControls = false, children }) => {
const { t } = useI18n();
const tabIds = useSessionTabsStore((state) => state.tabIds);
const ensureTab = useSessionTabsStore((state) => state.ensureTab);
const closeOtherTabs = useSessionTabsStore((state) => state.closeOtherTabs);
const reorderTabs = useSessionTabsStore((state) => state.reorderTabs);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const activeSessions = useGlobalSessionsStore((state) => state.activeSessions);
// Opening a session anywhere (sidebar, palette, deep link) adds its tab.
React.useEffect(() => {
if (currentSessionId) ensureTab(currentSessionId);
}, [currentSessionId, ensureTab]);
const sessionsById = React.useMemo(() => {
const map = new Map<string, Session>();
for (const session of activeSessions) map.set(session.id, session);
return map;
}, [activeSessions]);
// Only tabs with a known live session render; unknown ids stay stored.
const tabs = React.useMemo<SessionTab[]>(() => {
const list: SessionTab[] = [];
for (const id of tabIds) {
const session = sessionsById.get(id);
if (session) list.push({ id, session });
}
return list;
}, [tabIds, sessionsById]);
const handleSelect = React.useCallback((tab: SessionTab) => {
setCurrentSession(tab.id, resolveGlobalSessionDirectory(tab.session));
}, [setCurrentSession]);
const handleClose = React.useCallback((id: string) => {
closeSessionTabAndActivateNeighbour(id);
}, []);
const handleCloseOthers = React.useCallback((id: string) => {
closeOtherTabs(id);
if (currentSessionId && currentSessionId !== id) {
const kept = tabs.find((tab) => tab.id === id);
if (kept) handleSelect(kept);
}
}, [closeOtherTabs, currentSessionId, handleSelect, tabs]);
const sensors = useSensors(
useSensor(MouseSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
);
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
reorderTabs(String(active.id), String(over.id));
}
}, [reorderTabs]);
// Soft fade at the edges while more tabs hide behind them.
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [edges, setEdges] = React.useState({ left: false, right: false });
const updateEdges = React.useCallback(() => {
const node = scrollRef.current;
if (!node) return;
const left = node.scrollLeft > 2;
const right = node.scrollLeft + node.clientWidth < node.scrollWidth - 2;
setEdges((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
}, []);
React.useEffect(() => {
updateEdges();
const node = scrollRef.current;
if (!node || !globalThis.ResizeObserver) return;
const observer = new ResizeObserver(updateEdges);
observer.observe(node);
return () => observer.disconnect();
}, [updateEdges, tabs.length]);
// Keep the active tab in view when it changes.
React.useEffect(() => {
scrollRef.current
?.querySelector('[data-active-session-tab]')
?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}, [currentSessionId]);
const maskImage = edges.left && edges.right
? 'linear-gradient(to right, transparent, black 24px, black calc(100% - 24px), transparent)'
: edges.left
? 'linear-gradient(to right, transparent, black 24px)'
: edges.right
? 'linear-gradient(to right, black calc(100% - 24px), transparent)'
: undefined;
const tabIdsInOrder = React.useMemo(() => tabs.map((tab) => tab.id), [tabs]);
// A brand-new draft (no session yet) shows as a transient active pill after
// the tabs; it becomes a real tab once the first message creates the session.
const showDraftPill = !currentSessionId || !tabs.some((tab) => tab.id === currentSessionId);
return (
<div className="app-region-no-drag flex h-full min-w-0 flex-1 items-center" role="tablist" aria-label={t('header.sessionTabs.stripAria')}>
<div
ref={scrollRef}
onScroll={updateEdges}
className="session-tabs-scroll flex min-w-0 flex-1 items-center gap-1.5 overflow-x-auto overscroll-x-contain"
style={maskImage ? { maskImage, WebkitMaskImage: maskImage } : undefined}
>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToXAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIdsInOrder} strategy={horizontalListSortingStrategy}>
{tabs.map((tab) => (
<SessionTabItem
key={tab.id}
tab={tab}
isActive={tab.id === currentSessionId}
suppressControls={tab.id === currentSessionId && suppressActiveTabControls}
onSelect={handleSelect}
onClose={handleClose}
renderMenu={renderMenu}
closeOtherTabs={handleCloseOthers}
onMenuOpenChangeComplete={onMenuOpenChangeComplete}
>
{tab.id === currentSessionId ? children : null}
</SessionTabItem>
))}
</SortableContext>
</DndContext>
{showDraftPill ? (
<div
role="tab"
aria-selected
className="session-tab-slot flex h-7 w-44 shrink-0 items-center rounded-md bg-interactive-selection px-2"
data-active="true"
>
<div className="min-w-0 flex-1">{children}</div>
</div>
) : null}
</div>
</div>
);
};
@@ -56,10 +56,10 @@ const formatTime = (timestamp: number | null, timeFormatPreference: TimeFormatPr
// Width threshold for mobile vs desktop layout in settings
const MOBILE_WIDTH_THRESHOLD = 550;
// Width threshold for expanded layout (sidebar + chat side by side)
const EXPANDED_LAYOUT_THRESHOLD = 1400;
// Sessions sidebar width in expanded layout
const SESSIONS_SIDEBAR_WIDTH = 280;
// Keep enough room for the chat after adding the persistent sessions sidebar.
const EXPANDED_LAYOUT_THRESHOLD = SESSIONS_SIDEBAR_WIDTH + 520;
const SESSIONS_SIDEBAR_MIN_WIDTH = Math.round(SESSIONS_SIDEBAR_WIDTH * 0.7);
const SESSIONS_SIDEBAR_MAX_WIDTH = 520;
@@ -12,6 +12,7 @@ import { SortableContext, arrayMove, useSortable, verticalListSortingStrategy }
import { CSS as DndCSS } from '@dnd-kit/utilities';
import { Icon } from '@/components/icon/Icon';
import { Input } from '@/components/ui/input';
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -455,18 +456,18 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
return hiddenModels.some((hidden) => hidden.providerID === providerID && hidden.modelID === modelID);
}, [hiddenModels]);
const matchesQuery = React.useCallback((modelName: string, providerName: string) => {
const query = searchQuery.trim().toLowerCase();
if (!query) return true;
return modelName.toLowerCase().includes(query) || providerName.toLowerCase().includes(query);
}, [searchQuery]);
const matchesQuery = React.useCallback(
(modelName: string, providerName: string, modelID?: string) =>
matchesRankQuery([modelName, modelID, providerName], searchQuery),
[searchQuery],
);
const filteredFavorites = React.useMemo(() => favoriteModels.filter(({ model, providerID, modelID }) => {
if (allowedProviderSet && !allowedProviderSet.has(providerID)) return false;
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
if (isHidden(providerID, modelID)) return false;
const providerName = providerById.get(providerID)?.name || providerID;
return matchesQuery(getModelDisplayName(model), providerName);
return matchesQuery(getModelDisplayName(model), providerName, modelID);
}), [allowedProviderSet, favoriteModels, isHidden, isModelAllowed, matchesQuery, providerById]);
const filteredRecents = React.useMemo(() => recentModels.filter(({ model, providerID, modelID }) => {
@@ -474,7 +475,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
if (isModelAllowed && !isModelAllowed(providerID, modelID)) return false;
if (isHidden(providerID, modelID)) return false;
const providerName = providerById.get(providerID)?.name || providerID;
return matchesQuery(getModelDisplayName(model), providerName);
return matchesQuery(getModelDisplayName(model), providerName, modelID);
}), [allowedProviderSet, isHidden, isModelAllowed, matchesQuery, providerById, recentModels]);
const orderedProviders = React.useMemo(() => {
@@ -495,7 +496,7 @@ export const ModelPickerList: React.FC<ModelPickerListProps> = ({
const modelID = typeof model.id === 'string' ? model.id : '';
if (!modelID || isHidden(provider.id, modelID)) return false;
if (isModelAllowed && !isModelAllowed(provider.id, modelID)) return false;
return matchesQuery(getModelDisplayName(model), provider.name || provider.id);
return matchesQuery(getModelDisplayName(model), provider.name || provider.id, modelID);
});
return { ...provider, models: filteredModels };
})
@@ -152,8 +152,8 @@ const GeneralSectionContent: React.FC = () => {
{!isVSCode && <OpenChamberToolsSettings />}
<OpenChamberVisualSettings visibleSettings={[
'fileEditorKeymap',
...(!isVSCode ? ['sessionTabs' as const] : []),
'autoSaveEnabled',
'expandedEditorToolbar',
...(!isVSCode ? ['terminalQuickKeys' as const] : []),
...(!isVSCode ? ['terminalShell' as const] : []),
...(!isVSCode ? ['terminalLoginShell' as const] : []),
@@ -32,7 +32,6 @@ import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS,
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { getStoredMobileLayoutPreference, setStoredMobileLayoutPreference, type MobileLayoutPreference } from '@/lib/mobileLayoutPreference';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -151,17 +150,6 @@ const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
},
];
const MOBILE_LAYOUT_OPTIONS: Array<{ value: MobileLayoutPreference; labelKey: string }> = [
{
value: 'default',
labelKey: 'settings.openchamber.visual.option.mobileLayout.default',
},
{
value: 'new',
labelKey: 'settings.openchamber.visual.option.mobileLayout.new',
},
];
type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -278,7 +266,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar' | 'autoSaveEnabled';
type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs';
const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [
{ id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' },
@@ -314,6 +302,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const sessionGoalDefaultBudget = useUIStore(state => state.sessionGoalDefaultBudget);
const setSessionGoalDefaultBudget = useUIStore(state => state.setSessionGoalDefaultBudget);
const setShowReasoningTraces = useUIStore(state => state.setShowReasoningTraces);
const streamingAutoFollowEnabled = useUIStore(state => state.streamingAutoFollowEnabled);
const setStreamingAutoFollowEnabled = useUIStore(state => state.setStreamingAutoFollowEnabled);
const collapsibleThinkingBlocks = useUIStore(state => state.collapsibleThinkingBlocks);
const setCollapsibleThinkingBlocks = useUIStore(state => state.setCollapsibleThinkingBlocks);
@@ -327,8 +317,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const promptNavigatorEnabled = useUIStore(state => state.promptNavigatorEnabled);
const setStickyUserHeader = useUIStore(state => state.setStickyUserHeader);
const setPromptNavigatorEnabled = useUIStore(state => state.setPromptNavigatorEnabled);
const expandedEditorToolbar = useUIStore(state => state.expandedEditorToolbar);
const setExpandedEditorToolbar = useUIStore(state => state.setExpandedEditorToolbar);
const autoSaveEnabled = useUIStore(state => state.autoSaveEnabled);
const setAutoSaveEnabled = useUIStore(state => state.setAutoSaveEnabled);
const wideChatLayoutEnabled = useUIStore(state => state.wideChatLayoutEnabled);
@@ -362,6 +350,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const showTerminalQuickKeysOnDesktop = useUIStore(state => state.showTerminalQuickKeysOnDesktop);
const sessionTabsEnabled = useUIStore(state => state.sessionTabsEnabled);
const setSessionTabsEnabled = useUIStore(state => state.setSessionTabsEnabled);
const setShowTerminalQuickKeysOnDesktop = useUIStore(state => state.setShowTerminalQuickKeysOnDesktop);
const fileEditorKeymap = useUIStore(state => state.fileEditorKeymap);
const setFileEditorKeymap = useUIStore(state => state.setFileEditorKeymap);
@@ -510,11 +500,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
void updateDesktopSettings({ draftStartersVisible: enabled });
}, [setDraftStartersVisible]);
const handleExpandedEditorToolbarChange = React.useCallback((enabled: boolean) => {
setExpandedEditorToolbar(enabled);
void updateDesktopSettings({ expandedEditorToolbar: enabled });
}, [setExpandedEditorToolbar]);
const handleCollapsibleUserMessagesChange = React.useCallback((enabled: boolean) => {
setCollapsibleUserMessages(enabled);
void updateDesktopSettings({ collapsibleUserMessages: enabled });
@@ -629,12 +614,11 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const hasThemeSettings = shouldShow('theme') && !isVSCode;
const showWindowControlsPositionSetting = shouldShow('windowControlsPosition') && showWindowControlsPosition;
const hasLocalizationSettings = shouldShow('theme') || shouldShow('timeFormat') || shouldShow('weekStart');
const showMobileLayoutSetting = isMobile && isWebRuntime() && !isDesktopShell() && !isVSCode;
const hasAppearanceSettings = isVSCode
? hasLocalizationSettings
: (shouldShow('theme') || showWindowControlsPositionSetting || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
: (shouldShow('theme') || showWindowControlsPositionSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart'));
const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || (shouldShow('inputBarOffset') && isMobile);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('expandedEditorToolbar') && !isVSCode);
const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('autoSaveEnabled') || (shouldShow('sessionTabs') && !isVSCode && !isMobile);
const hasBehaviorSettings = shouldShow('mermaidRendering')
|| (shouldShow('sessionGoal') && !isVSCode)
|| shouldShow('userMessageRendering')
@@ -723,7 +707,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell]
: terminalLoginShells.filter((shell) => shell !== terminalShell));
};
const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState<MobileLayoutPreference>(() => getStoredMobileLayoutPreference());
const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => {
@@ -743,16 +726,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]);
const handleMobileLayoutPreferenceChange = React.useCallback((value: MobileLayoutPreference) => {
if (value === mobileLayoutPreference) {
return;
}
setMobileLayoutPreference(value);
setStoredMobileLayoutPreference(value);
window.location.reload();
}, [mobileLayoutPreference]);
const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') {
return;
@@ -877,21 +850,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
))}
</SettingsRadioGroup>
{showMobileLayoutSetting && (
<SettingsInset>
<SettingsStackedField label={t('settings.openchamber.visual.section.mobileLayout')}>
<SettingsChipGroup
value={mobileLayoutPreference}
options={MOBILE_LAYOUT_OPTIONS.map((option) => ({
value: option.value,
label: tUnsafe(option.labelKey),
}))}
onChange={handleMobileLayoutPreferenceChange}
aria-label={t('settings.openchamber.visual.section.mobileLayout')}
/>
</SettingsStackedField>
</SettingsInset>
)}
</div>
<div className={SETTINGS_FIELDS_STACK_CLASS}>
@@ -1486,25 +1444,6 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="appearance.auto-save-enabled"
/>
)}
{shouldShow('expandedEditorToolbar') && !isVSCode && (
<SettingsCheckboxRow
checked={expandedEditorToolbar}
onChange={handleExpandedEditorToolbarChange}
label={t('settings.openchamber.visual.field.expandedEditorToolbar')}
ariaLabel={t('settings.openchamber.visual.field.expandedEditorToolbarAria')}
settingsItem="appearance.expanded-editor-toolbar"
/>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<SettingsCheckboxRow
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
/>
)}
{showTerminalShellSetting && (
<SettingsStackedField
label={t('settings.openchamber.visual.field.terminalShell')}
@@ -1534,7 +1473,31 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
settingsItem="appearance.terminal-login-shell"
/>
)}
{shouldShow('terminalQuickKeys') && !isMobile && (
<SettingsCheckboxRow
checked={showTerminalQuickKeysOnDesktop}
onChange={setShowTerminalQuickKeysOnDesktop}
label={t('settings.openchamber.visual.field.terminalQuickKeys')}
ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')}
settingsItem="appearance.terminal-quick-keys"
info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')}
/>
)}
</div>
{shouldShow('sessionTabs') && !isVSCode && !isMobile && (
<SettingsControlGroup
title={t('settings.openchamber.visual.field.sessionTabsGroup')}
settingsItem="appearance.session-tabs"
>
<SettingsCheckboxRow
checked={sessionTabsEnabled}
onChange={setSessionTabsEnabled}
label={t('settings.openchamber.visual.field.sessionTabs')}
ariaLabel={t('settings.openchamber.visual.field.sessionTabsAria')}
info={t('settings.openchamber.visual.field.sessionTabsInfo')}
/>
</SettingsControlGroup>
)}
</SettingsSection>
)}
@@ -1878,6 +1841,20 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
)}
</SettingsSection>
)}
<SettingsSection
title={t('settings.openchamber.visual.section.streaming')}
settingsItem="chat.streaming"
contentClassName={SETTINGS_OPTION_STACK_CLASS}
>
<SettingsCheckboxRow
checked={streamingAutoFollowEnabled}
onChange={setStreamingAutoFollowEnabled}
label={t('settings.openchamber.visual.field.streamingAutoFollow')}
ariaLabel={t('settings.openchamber.visual.field.streamingAutoFollowAria')}
info={t('settings.openchamber.visual.field.streamingAutoFollowInfo')}
settingsItem="chat.streaming-auto-follow"
/>
</SettingsSection>
{(shouldShow('collapsibleUserMessages') || shouldShow('stickyUserHeader') || (shouldShow('promptNavigatorEnabled') && !isVSCode) || shouldShow('wideChatLayout') || shouldShow('splitAssistantMessageActions') || shouldShow('codeBlockLineWrap')) && (
<SettingsSection
@@ -1,3 +1,4 @@
import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -603,15 +604,9 @@ export const ProvidersPage: React.FC = () => {
</div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => {
const query = providerSearchQuery.toLowerCase();
const customLabel = t('settings.providers.page.custom.optionLabel');
const customMatches = !query
|| customLabel.toLowerCase().includes(query)
|| 'other'.includes(query)
|| 'custom'.includes(query);
const filtered = unconnectedProviders.filter(p => {
return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query);
});
const customMatches = matchesRankQuery([customLabel, 'other', 'custom'], providerSearchQuery);
const filtered = rankByQuery(unconnectedProviders, providerSearchQuery, (p) => [p.name || p.id, p.id]);
if (filtered.length === 0 && !customMatches) {
return <p className="py-4 text-center typography-meta text-muted-foreground">{t('settings.providers.page.connect.noProvidersFound')}</p>;
}
@@ -792,13 +787,10 @@ export const ProvidersPage: React.FC = () => {
? t('settings.providers.page.auth.useReconnectHint')
: t('settings.providers.page.auth.incompleteHint');
const filteredModels = providerModels.filter((model) => {
const name = typeof model?.name === 'string' ? model.name : '';
const id = typeof model?.id === 'string' ? model.id : '';
const query = modelQuery.trim().toLowerCase();
if (!query) return true;
return name.toLowerCase().includes(query) || id.toLowerCase().includes(query);
});
const filteredModels = rankByQuery(providerModels, modelQuery, (model) => [
typeof model?.name === 'string' ? model.name : '',
typeof model?.id === 'string' ? model.id : '',
]);
if (isCustomEditMode && isEditableCustomProvider && editingCustomFormInitial) {
return (
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import QRCode from 'qrcode';
import { Button } from '@/components/ui/button';
@@ -1255,13 +1256,10 @@ export const RemoteInstancesPage: React.FC = () => {
[createImportedInstance],
);
const filteredImportCandidates = React.useMemo(() => {
const query = sshHostSearch.trim().toLowerCase();
if (!query) return importCandidates;
return importCandidates.filter((candidate) => {
return candidate.host.toLowerCase().includes(query) || candidate.sshCommand.toLowerCase().includes(query);
});
}, [importCandidates, sshHostSearch]);
const filteredImportCandidates = React.useMemo(
() => rankByQuery(importCandidates, sshHostSearch, (candidate) => [candidate.host, candidate.sshCommand]),
[importCandidates, sshHostSearch],
);
// Opening a ready instance means pointing this window at the forwarded local
// URL — the same navigation the host switcher performs after its own connect.
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -265,14 +266,12 @@ export const SkillsCatalogPage: React.FC<SkillsCatalogPageProps> = ({ mode, onMo
const isSearching = search.trim().length > 0;
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
const matches = (item: SkillsCatalogItem) =>
item.skillName.toLowerCase().includes(q)
|| (item.description || '').toLowerCase().includes(q)
|| (item.frontmatterName || '').toLowerCase().includes(q);
if (isSearching) {
return sources.flatMap((src) => (itemsBySource[src.id] || []).filter(matches));
return rankByQuery(
sources.flatMap((src) => itemsBySource[src.id] || []),
search,
(item) => [item.skillName, item.frontmatterName, item.description],
);
}
if (!selectedSourceId) {
return [];
@@ -148,7 +148,6 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
const homeDirectory = useDirectoryStore((s) => s.homeDirectory);
const projects = useProjectsStore((s) => s.projects);
const addProject = useProjectsStore((s) => s.addProject);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const gitIdentityProfiles = useGitIdentitiesStore((s) => s.profiles);
@@ -411,11 +410,10 @@ export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = (
}, [onOpenChange]);
const openProjectDraft = React.useCallback((projectId: string, projectPath: string) => {
setActiveMainTab('chat');
if (isMobile) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: projectPath });
handleClose();
}, [handleClose, isMobile, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
}, [handleClose, isMobile, openNewSessionDraft, setSessionSwitcherOpen]);
const handleQuickAdd = React.useCallback((event: React.MouseEvent, path: string) => {
event.stopPropagation();
@@ -368,13 +368,9 @@ export function ScheduledTasksDialog() {
return;
}
setOpen(false);
if (isMobile) {
useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
useUIStore.getState().setActiveMainTab('files');
return;
}
useFilesViewTabsStore.getState().setSelectedPath(selectedProject.path, task.loopFile, { allowOutsideRoot: true });
useUIStore.getState().openContextFile(selectedProject.path, task.loopFile);
}, [isMobile, selectedProject?.path, setOpen]);
}, [selectedProject?.path, setOpen]);
const handleRunNow = React.useCallback(async (task: ScheduledTask) => {
if (!selectedProjectID) {
@@ -397,8 +393,7 @@ export function ScheduledTasksDialog() {
// this surface (MainLayout closes surfaces on session selection).
const project = projects.find((entry) => entry.id === selectedProjectID);
useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null);
useUIStore.getState().setActiveMainTab('chat');
}
}
} catch (error) {
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
} finally {
@@ -391,7 +391,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const updateProjectMeta = useProjectsStore((state) => state.updateProjectMeta);
const reorderProjects = useProjectsStore((state) => state.reorderProjects);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const toggleHelpDialog = useUIStore((state) => state.toggleHelpDialog);
@@ -848,7 +847,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
sessionSearchQuery,
setSessionSearchQuery,
setIsSessionSearchOpen,
setActiveMainTab,
setSessionSwitcherOpen,
setCurrentSession,
updateSessionTitle,
@@ -1698,7 +1696,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
alwaysShowActions={alwaysShowSidebarActions}
activeProjectId={activeProjectId}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree}
addSessionToFolder={addSessionToFolder}
@@ -1741,8 +1738,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
alwaysShowSidebarActions,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
setSessionSwitcherOpen,
openNewSessionDraftFromTree,
addSessionToFolder,
stableCreateFolderAndStartRename,
@@ -1763,12 +1759,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
useUIStore.getState().closeMainSurfaces();
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft();
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
}, [mobileVariant, openNewSessionDraft, setSessionSwitcherOpen]);
const renderChatsSection = React.useCallback((items: ActivityItem[]) => {
const chatsRoot = getChatsRootForHome(homeDirectory)
@@ -1850,12 +1845,11 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
setBulkDeleteConfirm,
});
const handleOpenMultiRunFromHeader = React.useCallback(() => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openMultiRunLauncher();
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
}, [mobileVariant, openMultiRunLauncher, setSessionSwitcherOpen]);
return (
// One shared tooltip provider for the whole sidebar: session tooltips open
@@ -1889,7 +1883,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
handleSessionSelect={stableHandleSessionSelect}
mobileVariant={mobileVariant}
openNewSessionDraft={openNewSessionDraft}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
sessionOwnerBySessionId={sessionOwnership.bySessionId}
/>
@@ -1959,7 +1952,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
alwaysShowActions={alwaysShowSidebarActions}
toggleProject={toggleProject}
setActiveProjectIdOnly={setActiveProjectIdOnly}
setActiveMainTab={setActiveMainTab}
setSessionSwitcherOpen={setSessionSwitcherOpen}
openNewSessionDraft={openNewSessionDraftFromTree}
openNewWorktreeDialog={openNewWorktreeDialog}
@@ -2033,8 +2025,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
open={newWorktreeDialogOpen}
onOpenChange={setNewWorktreeDialogOpen}
onWorktreeCreated={(worktreePath, options) => {
setActiveMainTab('chat');
if (mobileVariant) {
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
if (options?.sessionId) {
@@ -71,14 +71,12 @@ type SwitcherContentProps = {
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
const items = useSwitcherItems(true, { scopeProjectId });
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const { t } = useI18n();
const handleNewSession = React.useCallback(() => {
setActiveMainTab('chat');
onSelect();
openNewSessionDraft();
}, [onSelect, openNewSessionDraft, setActiveMainTab]);
}, [onSelect, openNewSessionDraft]);
const [expandedParents, setExpandedParents] = React.useState<Set<string>>(new Set());
const toggleParent = React.useCallback((sessionId: string) => {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { toast } from '@/components/ui';
@@ -186,13 +187,10 @@ export const MemorySection: React.FC<{
};
}, [markViewed, viewKey]);
const visibleEntries = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return entries;
return entries.filter((entry) => (
entry.title.toLowerCase().includes(needle) || entry.body.toLowerCase().includes(needle)
));
}, [entries, query]);
const visibleEntries = React.useMemo(
() => entries.filter((entry) => matchesRankQuery([entry.title, entry.body], query)),
[entries, query],
);
const handleDelete = React.useCallback(async (memoryId: string) => {
if (!await deleteEntry(scope, memoryId)) {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { toast } from '@/components/ui';
@@ -178,11 +179,10 @@ export const NotesSection: React.FC<{
const saveNoteBody = useProjectContextStore((state) => state.saveNoteBody);
const deleteNote = useProjectContextStore((state) => state.deleteNote);
const visibleNotes = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return notes;
return notes.filter((note) => note.body.toLowerCase().includes(needle));
}, [notes, query]);
const visibleNotes = React.useMemo(
() => notes.filter((note) => matchesRankQuery([note.body], query)),
[notes, query],
);
// The store keeps the failure reason; without passing it through, every
// failure looks identical to the user and tells them nothing about the cause.
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { toast } from '@/components/ui';
@@ -146,11 +147,10 @@ export const PlansSection: React.FC<{
[onTogglePinned, projectRef, t]
);
const visiblePlans = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return plans;
return plans.filter((plan) => plan.title.toLowerCase().includes(needle));
}, [plans, query]);
const visiblePlans = React.useMemo(
() => plans.filter((plan) => matchesRankQuery([plan.title], query)),
[plans, query],
);
const handleOpenPlan = React.useCallback(
(plan: ProjectPlanLink) => {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import {
DndContext,
@@ -183,11 +184,10 @@ export const TodosSection: React.FC<{
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
// Filtering is display-only: every handler above still edits the full list,
// so reordering or clearing while a filter is active cannot drop hidden items.
const visibleTodos = React.useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return todos;
return todos.filter((todo) => todo.text.toLowerCase().includes(needle));
}, [query, todos]);
const visibleTodos = React.useMemo(
() => todos.filter((todo) => matchesRankQuery([todo.text], query)),
[query, todos],
);
return (
<div className="space-y-2">
@@ -44,13 +44,11 @@ export const useProjectTodoSend = (options: {
const sendMessage = useSessionUIStore((state) => state.sendMessage);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
}, [setSessionSwitcherOpen]);
const sendToCurrentSession = React.useCallback(
(todoText: string) => {
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { Session } from '@opencode-ai/sdk/v2';
@@ -13,7 +14,6 @@ import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { sessionEvents } from '@/lib/sessionEvents';
import type { MainTab } from '@/stores/useUIStore';
import { SessionFolderItem } from '../SessionFolderItem';
import type { SortableDragHandleProps } from './sortableItems';
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
@@ -83,7 +83,6 @@ type Props = {
alwaysShowActions: boolean;
activeProjectId: string | null;
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string; target?: 'chat' | 'project' }) => void;
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
@@ -264,7 +263,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.activeProjectId === next.activeProjectId
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
&& prev.setActiveMainTab === next.setActiveMainTab
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
&& prev.openNewSessionDraft === next.openNewSessionDraft
&& prev.addSessionToFolder === next.addSessionToFolder
@@ -306,7 +304,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
alwaysShowActions,
activeProjectId,
setActiveProjectIdOnly,
setActiveMainTab,
setSessionSwitcherOpen,
openNewSessionDraft,
addSessionToFolder,
@@ -483,7 +480,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
return true;
}
const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery);
const folderMatches = matchesRankQuery([entry.folder.name], normalizedSessionSearchQuery);
if (folderMatches || entry.nodes.length > 0) {
keepByFolderId.set(folderId, true);
return true;
@@ -879,7 +876,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
depth={0}
onNewSession={() => {
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({
selectedProjectId: projectId,
@@ -1247,8 +1243,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
onClick={(event) => {
event.stopPropagation();
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
setActiveMainTab('chat');
if (mobileVariant) setSessionSwitcherOpen(false);
if (mobileVariant) setSessionSwitcherOpen(false);
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory });
}}
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
@@ -16,7 +16,6 @@ import type { SortableDragHandleProps } from './sortableItems';
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
import { formatProjectLabel } from './utils';
import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore';
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
import { streamPerfCount } from '@/stores/utils/streamDebug';
import { Icon } from '@/components/icon/Icon';
@@ -91,7 +90,6 @@ type Props = {
alwaysShowActions: boolean;
toggleProject: (id: string) => void;
setActiveProjectIdOnly: (id: string) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
openNewWorktreeDialog: () => void;
@@ -362,7 +360,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
}}
onNewSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
props.openNewSessionDraft({
selectedProjectId: projectKey,
@@ -371,7 +368,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
}}
onNewWorktreeSession={() => {
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
props.setActiveMainTab('chat');
props.openNewWorktreeDialog();
}}
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
@@ -2,7 +2,6 @@ import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
import { normalizePath } from '../utils';
import type { MainTab } from '@/stores/useUIStore';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -22,7 +21,6 @@ type Args = {
newSessionDraftOpen: boolean;
mobileVariant: boolean;
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
};
@@ -101,7 +99,6 @@ export const useProjectSessionSelection = (args: Args): void => {
newSessionDraftOpen,
mobileVariant,
openNewSessionDraft,
setActiveMainTab,
setSessionSwitcherOpen,
} = args;
@@ -205,7 +202,6 @@ export const useProjectSessionSelection = (args: Args): void => {
previousActiveProjectRef.current = activeProjectId;
if (selection.kind === 'open-draft') {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
@@ -232,7 +228,6 @@ export const useProjectSessionSelection = (args: Args): void => {
openNewSessionDraft,
projectSections,
projectSessionMeta,
setActiveMainTab,
setSessionSwitcherOpen,
setActiveSessionByProject,
]);
@@ -3,7 +3,6 @@ import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { copyTextToClipboard } from '@/lib/clipboard';
import { useI18n } from '@/lib/i18n';
import type { MainTab } from '@/stores/useUIStore';
import { useUIStore } from '@/stores/useUIStore';
import { streamPerfMark } from '@/stores/utils/streamDebug';
import { useSessionUIStore } from '@/sync/session-ui-store';
@@ -30,7 +29,6 @@ type Args = {
sessionSearchQuery: string;
setSessionSearchQuery: (value: string) => void;
setIsSessionSearchOpen: (open: boolean) => void;
setActiveMainTab: (tab: MainTab) => void;
setSessionSwitcherOpen: (open: boolean) => void;
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
updateSessionTitle: (id: string, title: string) => Promise<void>;
@@ -79,7 +77,6 @@ export const useSessionActions = (args: Args) => {
};
if (args.mobileVariant) {
args.setActiveMainTab('chat');
args.setSessionSwitcherOpen(false);
}
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
@@ -44,7 +45,7 @@ export const useSessionGrouping = (args: Args) => {
}
return nodes.flatMap((node) => {
const nodeMatches = buildSessionSearchText(node.session).includes(query);
const nodeMatches = matchesRankQuery([buildSessionSearchText(node.session)], query);
if (nodeMatches) {
return [node];
}
@@ -1,3 +1,4 @@
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
@@ -161,10 +162,10 @@ export const useSessionSidebarSections = (args: Args) => {
section.groups.forEach((group) => {
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
const matchedSessionCount = countNodes(filteredNodes);
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery);
const groupMatches = matchesRankQuery([buildGroupSearchText(group)], normalizedSessionSearchQuery);
const scopeKey = normalizePath(group.directory ?? null);
const scopeFolders = scopeKey ? (foldersMap[scopeKey] ?? []) : [];
const folderNameMatchCount = scopeFolders.filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length;
const folderNameMatchCount = scopeFolders.filter((folder) => matchesRankQuery([folder.name], normalizedSessionSearchQuery)).length;
result.set(group, {
filteredNodes,
@@ -245,6 +245,13 @@ const TerminalViewport = React.forwardRef<TerminalController, Props>(({
const fitAddon = new module.FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(container);
// ghostty-web marks the container contenteditable for touch IME input but
// sets autocapitalize/autocorrect only on its hidden textarea. Mobile
// keyboards (iOS and Android) therefore auto-capitalize the first letter
// of every terminal command; disable IME text mangling on the container.
container.setAttribute('autocapitalize', 'off');
container.setAttribute('autocorrect', 'off');
container.setAttribute('spellcheck', 'false');
terminalRef.current = terminal;
fitRef.current = fitAddon;
subscriptions = [terminal.onData((data) => inputRef.current(data))];
@@ -81,7 +81,6 @@ export const CommandPalette: React.FC = () => {
const isCommandPaletteOpen = useUIStore((s) => s.isCommandPaletteOpen);
const setCommandPaletteOpen = useUIStore((s) => s.setCommandPaletteOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setSettingsPage = useUIStore((s) => s.setSettingsPage);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
@@ -170,7 +169,6 @@ export const CommandPalette: React.FC = () => {
shortcutId: 'new_chat',
searchText: t('commandPalette.item.newSession'),
onSelect: run(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
}),
@@ -263,8 +261,7 @@ export const CommandPalette: React.FC = () => {
t,
run,
isMobile,
setActiveMainTab,
setSessionSwitcherOpen,
setSessionSwitcherOpen,
openNewSessionDraft,
toggleSidebar,
openContextSurface,
@@ -352,7 +349,7 @@ export const CommandPalette: React.FC = () => {
return;
}
let cancelled = false;
void searchFiles(currentRoot, trimmedQuery, 10, { type: 'file' })
void searchFiles(currentRoot, trimmedQuery, 40, { type: 'file' })
.then((results) => {
if (cancelled) return;
setFileResults(
+13 -101
View File
@@ -1,15 +1,17 @@
import React from "react";
import { useScrollShadow, type ScrollShadowOrientation, type ScrollShadowVisibility } from "./useScrollShadow";
export type ScrollShadowProps = React.HTMLAttributes<HTMLElement> & {
as?: React.ElementType;
orientation?: "vertical" | "horizontal";
orientation?: ScrollShadowOrientation;
offset?: number;
size?: number;
isEnabled?: boolean;
hideTopShadow?: boolean;
hideBottomShadow?: boolean;
observeMutations?: boolean;
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
onVisibilityChange?: (state: ScrollShadowVisibility) => void;
};
function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
@@ -44,7 +46,6 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
ref,
) => {
const internalRef = React.useRef<HTMLElement>(null);
const visibleRef = React.useRef<"both" | "none" | "top" | "bottom" | "left" | "right">("none");
const dataScrollShadow = (rest as Record<string, unknown>)["data-scroll-shadow"];
delete (rest as Record<string, unknown>)["data-scroll-shadow"];
@@ -57,104 +58,15 @@ export const ScrollShadow = React.forwardRef<HTMLElement, ScrollShadowProps>(
return next;
}, [size, style]);
const setAttributes = React.useCallback(
(el: HTMLElement, hasBefore: boolean, hasAfter: boolean, prefix: "top" | "left", suffix: "bottom" | "right") => {
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
if (hasBefore && hasAfter) {
(el.dataset as Record<string, string>)[bothKey] = "true";
el.removeAttribute(`data-${prefix}-scroll`);
el.removeAttribute(`data-${suffix}-scroll`);
} else {
el.dataset[`${prefix}Scroll`] = String(hasBefore);
el.dataset[`${suffix}Scroll`] = String(hasAfter);
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
}
},
[],
);
const clearAttributes = React.useCallback((el: HTMLElement) => {
["top", "bottom", "top-bottom", "left", "right", "left-right"].forEach((attr) => {
el.removeAttribute(`data-${attr}-scroll`);
});
}, []);
const checkOverflow = React.useCallback(() => {
const el = internalRef.current;
if (!el) return;
if (!isEnabled) {
clearAttributes(el);
return;
}
// Subpixel tolerance: on hi-DPI (Retina) and with fractional scrollTop,
// scrollTop+clientHeight can fall ~0.5px short of scrollHeight at the very end,
// which would otherwise keep the bottom fade visible after fully scrolling.
const SUBPIXEL_TOLERANCE = 1;
const hasBefore =
orientation === "vertical"
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
let hasAfter =
orientation === "vertical"
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
if (hideBottomShadow && orientation === "vertical") {
hasAfter = false;
}
setAttributes(el, effectiveHasBefore, hasAfter, orientation === "vertical" ? "top" : "left", orientation === "vertical" ? "bottom" : "right");
const next = effectiveHasBefore && hasAfter ? "both" : effectiveHasBefore ? (orientation === "vertical" ? "top" : "left") : hasAfter ? (orientation === "vertical" ? "bottom" : "right") : "none";
if (next !== visibleRef.current) {
visibleRef.current = next;
onVisibilityChange?.(next);
}
}, [clearAttributes, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation, setAttributes]);
React.useEffect(() => {
const el = internalRef.current;
if (!el) return;
// Throttle with RAF to avoid excessive calls during rapid DOM changes
let rafId: number | null = null;
const throttledCheck = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
checkOverflow();
});
};
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
checkOverflow();
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
// checkOverflow mutates our data-scroll attributes; observing attributes
// would make the component trigger its own observer indefinitely.
mutationObserver?.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener("scroll", handleScroll);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [checkOverflow, observeMutations]);
useScrollShadow(internalRef, {
orientation,
offset,
isEnabled,
hideTopShadow,
hideBottomShadow,
observeMutations,
onVisibilityChange,
});
return (
<Component
@@ -16,4 +16,27 @@ describe('commandPaletteFilesState', () => {
expect(scoreCommandPaletteFiles(fileResults, 'alpha', freshKey, staleKey)).toEqual([]);
expect(scoreCommandPaletteFiles(fileResults, 'alpha', freshKey, freshKey)).toHaveLength(1);
});
test('matches directory segments of the relative path, not just the basename', () => {
const fileResults = [
{ name: 'index.md', path: '/kb/solo-is-a-team-size/index.md', relativePath: 'solo-is-a-team-size/index.md' },
{ name: 'index.md', path: '/kb/software-developer/index.md', relativePath: 'software-developer/index.md' },
];
const key = buildCommandPaletteFileSearchKey('/kb', 'solo-is-a');
const scored = scoreCommandPaletteFiles(fileResults, 'solo-is-a', key, key);
expect(scored).toHaveLength(1);
expect(scored[0].item.relativePath).toBe('solo-is-a-team-size/index.md');
});
test('ranks prefix path matches above later substring matches', () => {
const fileResults = [
{ name: 'index.md', path: '/kb/notes/solo/index.md', relativePath: 'notes/solo/index.md' },
{ name: 'index.md', path: '/kb/solo-is-a-team-size/index.md', relativePath: 'solo-is-a-team-size/index.md' },
];
const key = buildCommandPaletteFileSearchKey('/kb', 'solo');
const scored = scoreCommandPaletteFiles(fileResults, 'solo', key, key);
expect(scored[0].item.relativePath).toBe('solo-is-a-team-size/index.md');
});
});
@@ -11,7 +11,7 @@ export const buildCommandPaletteFileSearchKey = (
return JSON.stringify([currentRoot, trimmedQuery]);
};
export const scoreCommandPaletteFiles = <T extends { name: string }>(
export const scoreCommandPaletteFiles = <T extends { name: string; relativePath: string }>(
fileResults: T[],
trimmedQuery: string,
fileSearchKey: string,
@@ -21,7 +21,9 @@ export const scoreCommandPaletteFiles = <T extends { name: string }>(
return [];
}
return scoreByFuzzyQuery(fileResults, trimmedQuery, (file) => file.name, {
// Score against the full relative path: queries like "solo-is-a" must match
// solo-is-a-team-size/index.md even though the basename is just index.md.
return scoreByFuzzyQuery(fileResults, trimmedQuery, (file) => file.relativePath || file.name, {
limit: 10,
threshold: 0.4,
});
+5 -1
View File
@@ -43,7 +43,11 @@ const variants = [
{textContent.split("").map((char, index) => (
<motion.span
{...props}
key={char + String(index)}
// Index-only: keying by character remounted every span when
// the text mutated (a tool title resolving on completion) and
// replayed the whole fade. Same-index spans update in place;
// appended characters still mount with the reveal.
key={index}
className={cn(
"inline-block whitespace-pre align-baseline"
)}
@@ -0,0 +1,159 @@
import React from "react";
// Scroll-shadow state as data attributes on a scroll container.
//
// The logic lives in a hook rather than only inside <ScrollShadow> because the
// chat timeline's scroll container is owned by the virtualized list component,
// which renders its own element — there is no wrapper to hand the styling to.
// <ScrollShadow> is a thin wrapper over this hook, so both paths stay in sync.
export type ScrollShadowOrientation = "vertical" | "horizontal";
export type ScrollShadowVisibility = "both" | "none" | "top" | "bottom" | "left" | "right";
export type UseScrollShadowOptions = {
orientation?: ScrollShadowOrientation;
offset?: number;
isEnabled?: boolean;
hideTopShadow?: boolean;
hideBottomShadow?: boolean;
observeMutations?: boolean;
onVisibilityChange?: (state: ScrollShadowVisibility) => void;
};
const SCROLL_SHADOW_ATTRIBUTES = [
"top",
"bottom",
"top-bottom",
"left",
"right",
"left-right",
] as const;
const clearScrollShadowAttributes = (el: HTMLElement): void => {
SCROLL_SHADOW_ATTRIBUTES.forEach((attr) => {
el.removeAttribute(`data-${attr}-scroll`);
});
};
const setScrollShadowAttributes = (
el: HTMLElement,
hasBefore: boolean,
hasAfter: boolean,
prefix: "top" | "left",
suffix: "bottom" | "right",
): void => {
const bothKey = `${prefix}${suffix.charAt(0).toUpperCase()}${suffix.slice(1)}Scroll` as const;
if (hasBefore && hasAfter) {
(el.dataset as Record<string, string>)[bothKey] = "true";
el.removeAttribute(`data-${prefix}-scroll`);
el.removeAttribute(`data-${suffix}-scroll`);
} else {
el.dataset[`${prefix}Scroll`] = String(hasBefore);
el.dataset[`${suffix}Scroll`] = String(hasAfter);
el.removeAttribute(`data-${prefix}-${suffix}-scroll`);
}
};
export const useScrollShadow = (
elementRef: React.RefObject<HTMLElement | null>,
{
orientation = "vertical",
offset = 0,
isEnabled = true,
hideTopShadow = false,
hideBottomShadow = false,
observeMutations = true,
onVisibilityChange,
}: UseScrollShadowOptions = {},
): void => {
const visibleRef = React.useRef<ScrollShadowVisibility>("none");
const checkOverflow = React.useCallback(() => {
const el = elementRef.current;
if (!el) return;
if (!isEnabled) {
clearScrollShadowAttributes(el);
return;
}
// Subpixel tolerance: on hi-DPI (Retina) and with fractional scrollTop,
// scrollTop+clientHeight can fall ~0.5px short of scrollHeight at the very end,
// which would otherwise keep the bottom fade visible after fully scrolling.
const SUBPIXEL_TOLERANCE = 1;
const hasBefore =
orientation === "vertical"
? el.scrollTop > offset + SUBPIXEL_TOLERANCE
: el.scrollLeft > offset + SUBPIXEL_TOLERANCE;
let hasAfter =
orientation === "vertical"
? el.scrollHeight - (el.scrollTop + el.clientHeight) > offset + SUBPIXEL_TOLERANCE
: el.scrollWidth - (el.scrollLeft + el.clientWidth) > offset + SUBPIXEL_TOLERANCE;
const effectiveHasBefore = hideTopShadow && orientation === "vertical" ? false : hasBefore;
if (hideBottomShadow && orientation === "vertical") {
hasAfter = false;
}
setScrollShadowAttributes(
el,
effectiveHasBefore,
hasAfter,
orientation === "vertical" ? "top" : "left",
orientation === "vertical" ? "bottom" : "right",
);
const next: ScrollShadowVisibility = effectiveHasBefore && hasAfter
? "both"
: effectiveHasBefore
? (orientation === "vertical" ? "top" : "left")
: hasAfter
? (orientation === "vertical" ? "bottom" : "right")
: "none";
if (next !== visibleRef.current) {
visibleRef.current = next;
onVisibilityChange?.(next);
}
}, [elementRef, hideTopShadow, hideBottomShadow, isEnabled, offset, onVisibilityChange, orientation]);
React.useEffect(() => {
const el = elementRef.current;
if (!el) return;
// Throttle with RAF to avoid excessive calls during rapid DOM changes
let rafId: number | null = null;
const throttledCheck = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
checkOverflow();
});
};
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
checkOverflow();
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
// checkOverflow mutates our data-scroll attributes; observing attributes
// would make the hook trigger its own observer indefinitely.
mutationObserver?.observe(el, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
el.removeEventListener("scroll", handleScroll);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [checkOverflow, elementRef, observeMutations]);
};
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { Icon } from '@/components/icon/Icon';
@@ -27,7 +28,6 @@ export function ArchiveView(): React.ReactNode {
const { t } = useI18n();
const open = useUIStore((state) => state.isArchivePageOpen);
const setOpen = useUIStore((state) => state.setArchivePageOpen);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
@@ -67,7 +67,7 @@ export function ArchiveView(): React.ReactNode {
// while not searching.
const filteredSessions = React.useMemo(() => {
if (normalizedQuery) {
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
return rankByQuery(sortedSessions, normalizedQuery, (session) => [session.title]);
}
if (selectedDirectory === null) return sortedSessions;
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
@@ -85,9 +85,8 @@ export function ArchiveView(): React.ReactNode {
const openSession = React.useCallback((session: Session) => {
const directory = normalizePath(resolveGlobalSessionDirectory(session));
setCurrentSession(session.id, directory ?? undefined);
setActiveMainTab('chat');
setOpen(false);
}, [setActiveMainTab, setCurrentSession, setOpen]);
}, [setCurrentSession, setOpen]);
const restoreSession = React.useCallback((session: Session) => {
void unarchiveSession(session.id).then((success) => {
@@ -1,102 +0,0 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useI18n } from '@/lib/i18n';
import { DiagramEditor, type DiagramEditorHandle } from '@/components/diagram';
import { Icon } from '@/components/icon/Icon';
export function DiagramView() {
const { t } = useI18n();
const { files } = useRuntimeAPIs();
const [filePath, setFilePath] = React.useState<string | null>(null);
const [xml, setXml] = React.useState('');
const [loading, setLoading] = React.useState(true);
const editorRef = React.useRef<DiagramEditorHandle>(null);
const pendingDiagramFile = useUIStore((state) => state.pendingDiagramFile);
const loadFile = React.useCallback(async (path: string) => {
setLoading(true);
setFilePath(path);
try {
const result = await files?.readFile?.(path);
if (result) {
setXml(result.content);
}
} catch {
setXml('');
} finally {
setLoading(false);
}
}, [files]);
React.useEffect(() => {
if (!pendingDiagramFile) {
return;
}
const pending = useUIStore.getState().consumePendingDiagramFile();
if (pending) {
void loadFile(pending);
}
}, [loadFile, pendingDiagramFile]);
const saveDiagram = React.useCallback(async () => {
const newXml = editorRef.current?.getXml();
if (filePath && files?.writeFile && newXml && newXml !== xml) {
await files.writeFile(filePath, newXml);
setXml(newXml);
}
}, [filePath, files, xml]);
const fileName = filePath ? filePath.split('/').pop() || filePath : '';
if (!filePath) {
return (
<div className="flex h-full items-center justify-center p-3">
<div className="typography-ui text-muted-foreground">
{t('filesView.editor.pickFileFromTree')}
</div>
</div>
);
}
if (loading) {
return (
<div className="flex h-full items-center justify-center p-3">
<Icon name="loader-4" className="size-4 animate-spin" />
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border/30 px-3 py-1.5">
<Icon name="file" className="size-4 shrink-0 text-muted-foreground" />
<span className="typography-ui text-muted-foreground truncate flex-1">{fileName}</span>
<button
type="button"
onClick={() => void saveDiagram()}
className="size-6 flex items-center justify-center rounded-md text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
title={t('filesView.diagram.saveDiagram')}
>
<Icon name="save-3" className="size-4" />
</button>
<button
type="button"
onClick={() => useUIStore.getState().setActiveMainTab('chat')}
className="size-6 flex items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
title={t('filesView.diagram.closeDiagramView')}
>
<Icon name="close" className="size-4" />
</button>
</div>
<div className="flex-1 min-h-0">
<DiagramEditor
ref={editorRef}
xml={xml}
className="h-full"
/>
</div>
</div>
);
}
@@ -8,6 +8,7 @@ import { coerceDiffScope, branchRangeKey, isBranchScopeAvailable, isBranchScopeD
import { getBranchBase, getGitRangeDiff, getGitRangeFiles } from '@/lib/gitApi';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { cn } from '@/lib/utils';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import type { GitStatus, GitRangeFileEntry } from '@/lib/api/types';
import {
DropdownMenu,
@@ -1953,12 +1954,11 @@ export const DiffView: React.FC<DiffViewProps> = ({
}
if (!branchBase) {
const searchTerm = basePickerSearch.trim().toLowerCase();
const candidateBranches = (branches?.all ?? [])
const eligibleBranches = (branches?.all ?? [])
.map((name: string) => name.replace(/^remotes\//, ''))
.filter((name: string) => name !== currentBranch && !name.endsWith(`/${currentBranch}`))
.filter((name: string) => !searchTerm || name.toLowerCase().includes(searchTerm))
.sort();
const candidateBranches = rankByQuery(eligibleBranches, basePickerSearch, (name) => [name]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
<Icon name="git-branch" className="size-6 text-muted-foreground" />
+5 -130
View File
@@ -750,8 +750,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [wrapLines, setWrapLines] = React.useState(true);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [isSearchOpen, setIsSearchOpen] = React.useState(false);
const [isFloatingToolbarOpen, setIsFloatingToolbarOpen] = React.useState(false);
const floatingToolbarRef = React.useRef<HTMLDivElement | null>(null);
const toolbarDropdownOpenCountRef = React.useRef(0);
const handleToolbarDropdownOpenChange = React.useCallback((open: boolean) => {
@@ -761,23 +759,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
);
}, []);
const isClickInsidePortalledMenu = React.useCallback((target: EventTarget | null) => {
if (!(target instanceof Element)) return false;
return target.closest('[data-slot="dropdown-menu-content"], [data-slot="dropdown-menu-item"]') !== null;
}, []);
React.useEffect(() => {
if (!isFloatingToolbarOpen) return;
const handler = (event: MouseEvent) => {
if (toolbarDropdownOpenCountRef.current > 0) return;
if (isClickInsidePortalledMenu(event.target)) return;
if (floatingToolbarRef.current && !floatingToolbarRef.current.contains(event.target as Node)) {
setIsFloatingToolbarOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [isClickInsidePortalledMenu, isFloatingToolbarOpen]);
type TextViewMode = 'view' | 'edit';
type PreviewViewMode = 'preview' | 'edit';
@@ -939,7 +920,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
const pendingSelectFileRef = React.useRef<FileNode | null>(null);
const pendingTabRef = React.useRef<import('@/stores/useUIStore').MainTab | null>(null);
const pendingClosePathRef = React.useRef<string | null>(null);
const skipDirtyOnceRef = React.useRef(false);
const copiedContentTimeoutRef = React.useRef<number | null>(null);
@@ -1029,7 +1009,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const [isDragging, setIsDragging] = React.useState(false);
// Session/config for sending comments
const setMainTabGuard = useUIStore((state) => state.setMainTabGuard);
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation);
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
@@ -1038,7 +1017,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap);
const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const settingsExpandedEditorToolbar = useUIStore((state) => state.expandedEditorToolbar);
// Global mouseup to end drag selection
React.useEffect(() => {
@@ -1098,10 +1076,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
React.useEffect(() => {
setLineSelection(null);
reset();
setMainTabGuard(null);
setDraftContent('');
setIsSaving(false);
}, [selectedFile?.path, reset, setMainTabGuard]);
}, [selectedFile?.path, reset]);
React.useEffect(() => {
setCommentSelection(lineSelection);
@@ -1711,32 +1688,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
}, [contentDetectedBinary, draftContent, fileContent, fileLoading, files, isDirty, loadedFileLineEnding, loadedFilePath, readFileStat, root, selectedFile, t]);
React.useEffect(() => {
if (!isDirty) {
setMainTabGuard(null);
return;
}
const guard = (_nextTab: import('@/stores/useUIStore').MainTab) => {
if (skipDirtyOnceRef.current) {
skipDirtyOnceRef.current = false;
return true;
}
setConfirmDiscardOpen(true);
pendingTabRef.current = _nextTab;
return false;
};
setMainTabGuard(guard);
return () => {
const currentGuard = useUIStore.getState().mainTabGuard;
if (currentGuard === guard) {
setMainTabGuard(null);
}
};
}, [isDirty, setMainTabGuard]);
React.useEffect(() => {
if (autoSaveEnabled) {
return;
@@ -2136,11 +2087,9 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
const discardAndContinue = React.useCallback(() => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
const closePath = pendingClosePathRef.current;
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
pendingClosePathRef.current = null;
// Allow one guarded navigation (tab/file) without re-opening dialog.
@@ -2179,15 +2128,10 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setMainTabGuard, setSelectedPath]);
}, [displayedContent, handleSelectFile, isMobile, removeOpenPath, root, selectedFile?.path, setSelectedPath]);
const saveAndContinue = React.useCallback(async () => {
const nextFile = pendingSelectFileRef.current;
const nextTab = pendingTabRef.current;
const closePath = pendingClosePathRef.current;
const saved = await saveDraft();
@@ -2197,7 +2141,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
}
pendingSelectFileRef.current = null;
pendingTabRef.current = null;
pendingClosePathRef.current = null;
// We'll proceed after saving; suppress guard reopening.
@@ -2233,11 +2176,7 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
return;
}
if (nextTab) {
setMainTabGuard(null);
useUIStore.getState().setActiveMainTab(nextTab);
}
}, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setMainTabGuard, setSelectedPath]);
}, [handleSelectFile, isMobile, removeOpenPath, root, saveDraft, selectedFile?.path, setSelectedPath]);
const handleCloseFile = React.useCallback((path: string) => {
const isActive = selectedFile?.path === path;
@@ -3782,9 +3721,8 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
) : null}
{/* Row 2: Docked editor toolbar (expanded). Desktop opt-in; ALWAYS on
for mobile floating hover controls don't work with touch. */}
{(settingsExpandedEditorToolbar || isMobile) && selectedFile ? (
{/* Row 2: Docked editor toolbar. */}
{selectedFile ? (
<div className="flex min-w-0 items-center gap-3 border-t border-border/40 bg-[var(--surface-subtle)] px-3 py-1">
{/* Mobile hosts already show the file name in their own header;
a truncated duplicate here just eats toolbar width. */}
@@ -3805,69 +3743,6 @@ export const FilesView: React.FC<FilesViewProps> = ({ mode = 'full' }) => {
</div>
<div className="flex-1 min-h-0 min-w-0 relative">
{selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar || isMobile) && (
<div
ref={floatingToolbarRef}
className="absolute right-3 top-3 z-30"
onMouseLeave={() => {
if (toolbarDropdownOpenCountRef.current > 0) return;
setIsFloatingToolbarOpen(false);
}}
>
{isFloatingToolbarOpen ? (
renderFloatingFileControls()
) : (
<div className="flex items-center gap-1">
{isMarkdown ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
variant="ghost"
size="sm"
onClick={() => saveMdViewMode(getMdViewMode() === 'preview' ? 'edit' : 'preview')}
className={cn(
'size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 shadow-sm transition-colors',
getMdViewMode() === 'preview'
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selection-foreground)] hover:bg-[var(--interactive-selection)]'
: 'text-muted-foreground hover:text-foreground'
)}
aria-label={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
title={t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
>
<Icon name={getMdViewMode() === 'preview' ? 'eye' : 'eye-off'} className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t(getMdViewMode() === 'preview' ? 'filesView.editor.switchToEditMode' : 'filesView.editor.switchToPreviewMode')}
</TooltipContent>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex"
onMouseEnter={() => setIsFloatingToolbarOpen(true)}
>
<Button
variant="ghost"
size="sm"
onClick={() => setIsFloatingToolbarOpen(true)}
className="size-8 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-0 text-muted-foreground shadow-sm hover:text-foreground"
aria-label={t('filesView.editor.showControlsAria')}
title={t('filesView.editor.controlsTitle')}
>
<Icon name="more-2-fill" className="size-4" />
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>{t('filesView.editor.controlsTitle')}</TooltipContent>
</Tooltip>
</div>
)}
</div>
)}
<ScrollableOverlay outerClassName="h-full min-w-0" className="h-full min-w-0">
{!selectedFile ? (
<div className="p-3 typography-ui text-muted-foreground">{t('filesView.editor.pickFileFromTree')}</div>
+4 -13
View File
@@ -3,6 +3,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useConfigStore } from '@/stores/useConfigStore';
import { useFireworksCelebration } from '@/contexts/FireworksContext';
import type { GitIdentityProfile, CommitFileEntry, GitStatus } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useGitIdentitiesStore } from '@/stores/useGitIdentitiesStore';
import { useShallow } from 'zustand/react/shallow';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
@@ -2585,7 +2586,8 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
<DialogHeader className="px-4 pt-4">
<DialogTitle>{t('gitView.gitmoji.title')}</DialogTitle>
</DialogHeader>
<Command className="h-[420px]">
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
<Command className="h-[420px]" shouldFilter={false}>
<CommandInput
placeholder={t('gitView.gitmoji.searchPlaceholder')}
value={gitmojiSearch}
@@ -2594,18 +2596,7 @@ export const GitView: React.FC<GitViewProps> = ({ isActive }) => {
<CommandList>
<CommandEmpty>{t('gitView.gitmoji.empty')}</CommandEmpty>
<CommandGroup>
{(gitmojiEmojis.length === 0
? []
: gitmojiEmojis.filter((entry) => {
const term = gitmojiSearch.trim().toLowerCase();
if (!term) return true;
return (
entry.emoji.includes(term) ||
entry.code.toLowerCase().includes(term) ||
entry.description.toLowerCase().includes(term)
);
})
).map((entry) => (
{rankByQuery(gitmojiEmojis, gitmojiSearch, (entry) => [entry.code, entry.description, entry.emoji]).map((entry) => (
<CommandItem
key={entry.code}
onSelect={() => handleSelectGitmoji(entry.emoji, entry.code)}
@@ -168,7 +168,6 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const gitDirectories = useGitStore((state) => state.directories);
const effectiveDirectory = useEffectiveDirectory() ?? '';
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const runtimeApis = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
@@ -579,10 +578,9 @@ export const PlanView: React.FC<PlanViewProps> = ({ targetPath = null, projectPl
}, []);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
onNavigatedToChat?.();
}, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]);
}, [onNavigatedToChat, setSessionSwitcherOpen]);
const handleConfirmPlanSend = React.useCallback(
async (execution: TodoSendExecution) => {
@@ -58,6 +58,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
const setActiveTab = useTerminalStore((s) => s.setActiveTab);
const closeTab = useTerminalStore((s) => s.closeTab);
const setTabSessionId = useTerminalStore((s) => s.setTabSessionId);
const adoptServerSessions = useTerminalStore((s) => s.adoptServerSessions);
const setTabLifecycle = useTerminalStore((s) => s.setTabLifecycle);
const setConnecting = useTerminalStore((s) => s.setConnecting);
const appendToBuffer = useTerminalStore((s) => s.appendToBuffer);
@@ -147,9 +148,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
terminalControllerRef.current?.focus();
}, [useTouchTerminalInput]);
const activeSurface = useUIStore((state) => state.activeSurface);
const isTerminalActive = activeSurface === 'terminal';
const isTerminalVisible = visible ?? isTerminalActive;
const isTerminalVisible = visible ?? false;
const [hasOpenedTerminalViewport, setHasOpenedTerminalViewport] = React.useState(isTerminalVisible);
React.useEffect(() => {
@@ -176,6 +175,50 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible }) => {
directoryRef.current = effectiveDirectory;
}, [effectiveDirectory]);
// The tab list is a per-client projection, so ask the server what actually
// exists for this directory and adopt sessions no local tab references
// (another device, a fresh browser tab, or a reload with cleared storage).
// A failed listing changes nothing: adoption is additive only.
React.useEffect(() => {
if (!terminalHydrated || !effectiveDirectory || !terminal.listSessions) {
return;
}
let cancelled = false;
const directory = effectiveDirectory;
void terminal.listSessions(directory)
.then((serverSessions) => {
if (cancelled || directoryRef.current !== directory) return;
adoptServerSessions(directory, serverSessions);
})
.catch(() => { /* keep local tabs; the next mount or directory switch retries */ });
return () => {
cancelled = true;
};
}, [terminalHydrated, effectiveDirectory, terminal, adoptServerSessions]);
// The server reaps terminals with no attached socket after an idle timeout,
// but only the active tab holds an attachment. While this client is open,
// periodically mark every session its tabs reference as active so
// background tabs (and other directories' terminals) are not reaped.
React.useEffect(() => {
if (!terminal.touchSessions) {
return;
}
const touch = () => {
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
const ids: string[] = [];
for (const dirState of useTerminalStore.getState().sessions.values()) {
for (const tab of dirState.tabs) {
if (tab.terminalSessionId) ids.push(tab.terminalSessionId);
}
}
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
};
touch();
const interval = setInterval(touch, 10 * 60 * 1000);
return () => clearInterval(interval);
}, [terminal]);
React.useEffect(() => {
if (!showQuickKeys && activeModifier !== null) {
setActiveModifier(null);
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { toast } from '@/components/ui';
import { Input } from '@/components/ui/input';
@@ -214,13 +215,10 @@ export const AgentManagerSidebar: React.FC<AgentManagerSidebarProps> = ({
const MAX_VISIBLE = 5;
const filteredGroups = React.useMemo(() => {
if (!searchQuery.trim()) return groups;
const query = searchQuery.toLowerCase();
return groups.filter(group =>
group.name.toLowerCase().includes(query)
);
}, [searchQuery, groups]);
const filteredGroups = React.useMemo(
() => rankByQuery(groups, searchQuery, (group) => [group.name]),
[searchQuery, groups],
);
const visibleGroups = showAll ? filteredGroups : filteredGroups.slice(0, MAX_VISIBLE);
const remainingCount = filteredGroups.length - MAX_VISIBLE;
@@ -26,6 +26,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { Icon } from "@/components/icon/Icon";
import { cn } from '@/lib/utils';
import { dropdownTriggerVariants } from '@/components/ui/dropdown-trigger';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
type OperationType = 'merge' | 'rebase';
@@ -94,22 +95,19 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
// Filter branches based on search
const filteredLocal = React.useMemo(() => {
const term = branchSearch.toLowerCase();
const remoteBranchNames = new Set(
remoteBranches
.map((branch) => branch.slice(branch.indexOf('/') + 1))
.filter(Boolean)
);
const filtered = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
if (!term) return filtered;
return filtered.filter((b) => b.toLowerCase().includes(term));
const candidates = localBranches.filter((branch) => branch !== currentBranch && !remoteBranchNames.has(branch));
return rankByQuery(candidates, branchSearch, (branch) => [branch]);
}, [branchSearch, localBranches, currentBranch, remoteBranches]);
const filteredRemote = React.useMemo(() => {
const term = branchSearch.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [branchSearch, remoteBranches]);
const filteredRemote = React.useMemo(
() => rankByQuery(remoteBranches, branchSearch, (branch) => [branch]),
[branchSearch, remoteBranches]
);
const resolveDefaultBranch = React.useCallback(() => {
if (!defaultTargetBranch) return null;
@@ -321,7 +319,8 @@ export const BranchIntegrationSection: React.FC<BranchIntegrationSectionProps> =
sideOffset={6}
className="w-[var(--anchor-width)] p-0 max-h-[min(var(--available-height),24rem)] flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
<Command className="h-full min-h-0" shouldFilter={false}>
<CommandInput
ref={searchInputRef}
placeholder={t('gitView.branch.searchPlaceholder')}
@@ -17,6 +17,7 @@ import {
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Icon } from "@/components/icon/Icon";
import type { GitRemote } from '@/lib/api/types';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { useI18n } from '@/lib/i18n';
interface BranchInfo {
@@ -78,17 +79,15 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
[newBranchName]
);
const filteredLocal = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return localBranches;
return localBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, localBranches]);
const filteredLocal = React.useMemo(
() => rankByQuery(localBranches, search, (branch) => [branch]),
[search, localBranches]
);
const filteredRemote = React.useMemo(() => {
const term = search.toLowerCase();
if (!term) return remoteBranches;
return remoteBranches.filter((b) => b.toLowerCase().includes(term));
}, [search, remoteBranches]);
const filteredRemote = React.useMemo(
() => rankByQuery(remoteBranches, search, (branch) => [branch]),
[search, remoteBranches]
);
const handleCheckout = (branch: string) => {
if (branch === currentBranch) {
@@ -184,7 +183,9 @@ export const BranchSelector: React.FC<BranchSelectorProps> = ({
</Tooltip>
<DropdownMenuContent align="start" className="w-72 p-0 max-h-[60vh] flex flex-col">
<Command className="h-full min-h-0">
{/* Filtering and ordering are owned by rankByQuery above; cmdk's own
filter would re-filter and reorder the already-ranked rows. */}
<Command className="h-full min-h-0" shouldFilter={false}>
<CommandInput
placeholder={t('gitView.branch.searchPlaceholder')}
value={search}
@@ -10,7 +10,6 @@ import { Button } from '@/components/ui/button';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { getConflictDetails, type MergeConflictDetails } from '@/lib/gitApi';
@@ -41,7 +40,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setPendingInputText = useInputStore((state) => state.setPendingInputText);
const setPendingSyntheticParts = useInputStore((state) => state.setPendingSyntheticParts);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const [isLoading, setIsLoading] = React.useState(false);
const [conflictDetails, setConflictDetails] = React.useState<MergeConflictDetails | null>(null);
@@ -137,7 +135,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
{ text: context.payloadText, synthetic: true },
]);
setActiveMainTab('chat');
onClearState?.();
onOpenChange(false);
};
@@ -159,7 +156,6 @@ export const ConflictDialog: React.FC<ConflictDialogProps> = ({
],
});
// Navigate to chat tab so user sees the new session
setActiveMainTab('chat');
onClearState?.();
onOpenChange(false);
};
@@ -18,7 +18,7 @@ import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useInputStore } from '@/sync/input-store';
import { useUIStore } from '@/stores/useUIStore';
import { rankByQuery } from '@/lib/search/fuzzySearch';
import { getGitCommitSummaries } from '@/lib/gitApi';
import { renderMagicPrompt } from '@/lib/magicPrompts';
import {
@@ -64,10 +64,15 @@ export const IntegrateCommitsSection: React.FC<{
}) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const [branchDropdownOpen, setBranchDropdownOpen] = React.useState(false);
const [branchSearch, setBranchSearch] = React.useState('');
const searchInputRef = React.useRef<HTMLInputElement>(null);
const filteredBranches = React.useMemo(
() => rankByQuery(localBranches, branchSearch, (branch) => [branch]),
[localBranches, branchSearch]
);
const [targetBranch, setTargetBranch] = React.useState<string>(defaultTargetBranch);
React.useEffect(() => {
setTargetBranch(defaultTargetBranch);
@@ -228,8 +233,6 @@ export const IntegrateCommitsSection: React.FC<{
{ text: context.payloadText, synthetic: true },
],
});
// Navigate to chat tab so user sees the new session
setActiveMainTab('chat');
return;
}
@@ -244,8 +247,7 @@ export const IntegrateCommitsSection: React.FC<{
{ text: context.instructionsText, synthetic: true },
{ text: context.payloadText, synthetic: true },
]);
setActiveMainTab('chat');
}, [currentSessionId, setActiveMainTab, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]);
}, [currentSessionId, buildConflictContext, openNewSessionDraft, setPendingInputText, setPendingSyntheticParts, t]);
const handleMove = React.useCallback(async () => {
if (ui.kind !== 'ready') return;
@@ -380,10 +382,13 @@ export const IntegrateCommitsSection: React.FC<{
align="end"
className="w-72 p-0 max-h-[var(--available-height)] flex flex-col overflow-hidden"
>
<Command className="h-full min-h-0">
{/* rankByQuery owns filtering/ordering; cmdk must not re-filter. */}
<Command className="h-full min-h-0" shouldFilter={false}>
<CommandInput
ref={searchInputRef}
placeholder={t('gitView.branch.searchPlaceholder')}
value={branchSearch}
onValueChange={setBranchSearch}
onKeyDown={(event) => event.stopPropagation()}
/>
<CommandList
@@ -393,7 +398,7 @@ export const IntegrateCommitsSection: React.FC<{
>
<CommandEmpty>{t('gitView.branch.empty')}</CommandEmpty>
<CommandGroup heading={t('gitView.branch.localBranches')}>
{localBranches.map((branch) => (
{filteredBranches.map((branch) => (
<CommandItem
key={branch}
value={branch}
@@ -401,6 +406,7 @@ export const IntegrateCommitsSection: React.FC<{
setTargetBranch(branch);
persistTarget(branch);
setBranchDropdownOpen(false);
setBranchSearch('');
}}
>
{branch}
@@ -327,7 +327,6 @@ export const PullRequestSection: React.FC<{
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const { isMobile, hasTouchInput, screenWidth } = useDeviceInfo();
@@ -986,14 +985,13 @@ export const PullRequestSection: React.FC<{
text: '',
});
}
setActiveMainTab('chat');
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('gitView.pr.toast.loadChecksFailed'), { description: message });
} finally {
setIsAttachingChecks(false);
}
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t]);
}, [directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t]);
const sendCommentsToChat = React.useCallback(async () => {
if (!github?.prContext) {
@@ -1021,14 +1019,13 @@ export const PullRequestSection: React.FC<{
for (const comment of timelineComments) {
attachCommentDraft(target, comment);
}
setActiveMainTab('chat');
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error(t('gitView.pr.toast.loadPrCommentsFailed'), { description: message });
} finally {
setIsAttachingComments(false);
}
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, setActiveMainTab, status?.repo, t, timelineComments]);
}, [attachCommentDraft, directory, ensurePrContext, github, pr, resolveDraftTarget, status?.repo, t, timelineComments]);
const sendSingleCommentToChat = React.useCallback(async (comment: TimelineCommentItem) => {
const target = resolveDraftTarget();
@@ -1037,8 +1034,7 @@ export const PullRequestSection: React.FC<{
}
attachCommentDraft(target, comment);
setActiveMainTab('chat');
}, [attachCommentDraft, resolveDraftTarget, setActiveMainTab]);
}, [attachCommentDraft, resolveDraftTarget]);
const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => {
await refreshPrStatus(prStatusKey, options);
@@ -1,3 +1,4 @@
import { rankByQuery } from '@/lib/search/fuzzySearch';
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
@@ -69,11 +70,10 @@ export const StashesDialog: React.FC<StashesDialogProps> = ({
};
}, [directory, open, stashes]);
const filtered = React.useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) return stashes;
return stashes.filter((stash) => `${stash.ref} ${stash.message} ${stash.relativeTime}`.toLowerCase().includes(normalized));
}, [query, stashes]);
const filtered = React.useMemo(
() => rankByQuery(stashes, query, (stash) => [stash.message, stash.ref, stash.relativeTime]),
[query, stashes],
);
const refreshAfterChange = React.useCallback(async (change?: { affectsIndex?: boolean }) => {
await load();
@@ -1,29 +0,0 @@
import React from 'react';
import type { MotionValue } from 'motion/react';
export interface DrawerContextValue {
leftDrawerOpen: boolean;
rightDrawerOpen: boolean;
toggleLeftDrawer: () => void;
toggleRightDrawer: () => void;
// Motion values for real-time drawer dragging
leftDrawerX: MotionValue<number>;
rightDrawerX: MotionValue<number>;
leftDrawerWidth: React.MutableRefObject<number>;
rightDrawerWidth: React.MutableRefObject<number>;
setMobileLeftDrawerOpen: (open: boolean) => void;
setRightSidebarOpen: (open: boolean) => void;
}
const DrawerContext = React.createContext<DrawerContextValue | null>(null);
export const DrawerProvider: React.FC<{
children: React.ReactNode;
value: DrawerContextValue;
}> = ({ children, value }) => {
return (
<DrawerContext.Provider value={value}>
{children}
</DrawerContext.Provider>
);
};
-938
View File
@@ -1,938 +0,0 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
type AutoFollowState = 'following' | 'released';
export type ContentChangeReason = 'text' | 'structural' | 'permission' | 'animation';
export interface AnimationHandlers {
onChunk: () => void;
onComplete: () => void;
onStreamingCandidate?: () => void;
onAnimationStart?: () => void;
onReservationCancelled?: () => void;
onReasoningBlock?: () => void;
onAnimatedHeightChange?: (height: number) => void;
}
interface UseChatAutoFollowOptions {
currentSessionId: string | null;
currentSessionKey: string | null;
sessionMessageCount: number;
sessionIsWorking: boolean;
isMobile: boolean;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatAutoFollowResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
state: AutoFollowState;
isPinned: boolean;
isOverflowing: boolean;
isFollowingProgrammatically: boolean;
showScrollButton: boolean;
notifyContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void;
releaseAutoFollow: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
}
// ──────────────────────────────────────────────────────────────────────────
// Chat auto-follow. The model is deliberately simple, which is what makes it
// flicker-free:
//
// • Auto-follow is on unless the user scrolled up (`released`), AND passive
// following only acts while the session is active (working, plus a short
// settle window). When idle, content-size changes are layout churn
// (virtualizer re-measurement, async tool/code rendering) rather than live
// growth, so the hook leaves scroll alone — re-pinning then would fight the
// virtualizer and twitch the viewport.
// • Following the bottom is INSTANT — `scrollTop = scrollHeight` inside the
// content ResizeObserver, which fires after layout and before paint. There
// is NO easing loop and NO settle burst, so there are never two writers
// racing for `scrollTop` (the root cause of the old jiggle/double-scroll).
// • A short-lived "auto" marker (position + 1500ms) lets the scroll handler
// distinguish our own programmatic writes from genuine user scrolling, so
// a scroll event that lands at our just-written bottom never trips a false
// release.
//
// The public interface below is unchanged from the old implementation so every
// consumer (ChatContainer, message parts, the timeline controller) keeps
// working without edits.
// ──────────────────────────────────────────────────────────────────────────
const BOTTOM_SPACER_DESKTOP_VH = 0.10;
const BOTTOM_SPACER_MOBILE_PX = 40;
const SAVE_DEBOUNCE_MS = 150;
const TOUCH_FINGER_DOWN_THRESHOLD = 2;
// How long an "auto" (programmatic) scroll position stays trusted. Browsers can
// dispatch the `scroll` event for our write asynchronously, after newer content
// has already changed the geometry; the window keeps us from reading that lag as
// a user scroll.
const AUTO_MARK_TTL_MS = 1500;
const AUTO_MATCH_TOLERANCE_PX = 2;
// While a tracked height animation runs (e.g. a Thinking block auto-collapsing
// mid-stream), the timeline shrinks/grows over a couple hundred ms and the
// virtualizer re-measures, producing transient geometry. Browsers dispatch the
// resulting `scroll` events asynchronously, so a stale event can land after we
// have already re-pinned — its position matching neither the bottom zone nor the
// freshly-moved auto marker — and be misread as a user scroll-away. During this
// guard window we treat any `following`-state scroll event as our own and never
// release via the heuristic. GENUINE user gestures still release instantly
// through releaseFromUserIntent, so this is not glue. Sized to the reasoning
// animation (200ms) plus headroom for trailing async scroll events.
const ANIMATION_GUARD_MS = 350;
// After streaming stops, keep following the bottom for a short window so the
// final content can settle into place.
const SETTLE_MS = 300;
// Entry-stick window. On the FIRST open of a session, late async data (most
// visibly a task/subagent tool whose nested rows are fetched from the child
// session after entry — see useEnsureSessionMessages in ToolPart.tsx) grows the
// timeline a beat or two AFTER we have already pinned to the bottom, leaving the
// viewport stranded mid-history. The steady-state idle gate deliberately ignores
// that growth (it can't tell entry from a user reading idle history). So instead
// of weakening the gate, we open a short, gesture-cancellable window on entry
// during which we FORCE the bottom on every growth. It ends QUIESCENCE_MS after
// growth stops (capped by MAX_MS), or instantly on any real user scroll gesture.
const ENTRY_STICK_QUIESCENCE_MS = 600;
const ENTRY_STICK_MAX_MS = 8000;
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now());
// The bottom of the chat has an empty spacer (10vh on desktop, 40px on mobile)
// — its height is exactly how far above scrollHeight the user can be while still
// looking at "empty" space. We use that same value as the threshold for both
// re-pinning auto-follow and showing the scroll-to-bottom button.
const computeBottomZoneThreshold = (isMobile: boolean, container?: HTMLElement | null): number => {
if (isMobile) return BOTTOM_SPACER_MOBILE_PX;
const height = container?.clientHeight ?? 0;
if (height <= 0) return 96;
return Math.max(48, height * BOTTOM_SPACER_DESKTOP_VH);
};
const distanceFromBottom = (el: HTMLElement): number => {
return el.scrollHeight - el.scrollTop - el.clientHeight;
};
const canScroll = (el: HTMLElement): boolean => {
return el.scrollHeight - el.clientHeight > 1;
};
const isNearBottom = (el: HTMLElement, isMobile: boolean): boolean => {
return distanceFromBottom(el) <= computeBottomZoneThreshold(isMobile, el);
};
const isReleaseKey = (event: KeyboardEvent): boolean => {
if (event.altKey || event.ctrlKey || event.metaKey) {
return false;
}
switch (event.key) {
case 'ArrowUp':
case 'PageUp':
case 'Home':
return true;
default:
return false;
}
};
const nestedScrollableTarget = (root: HTMLElement, target: EventTarget | null): HTMLElement | null => {
if (!(target instanceof Element)) return null;
const nested = target.closest('[data-scrollable]');
if (!nested || nested === root || !(nested instanceof HTMLElement)) return null;
return nested;
};
const nestedScrollableCanConsumeUp = (root: HTMLElement, target: EventTarget | null): boolean => {
const nested = nestedScrollableTarget(root, target);
if (!nested) return false;
return nested.scrollTop > 0;
};
export const useChatAutoFollow = ({
currentSessionId,
currentSessionKey,
sessionMessageCount,
sessionIsWorking,
isMobile,
onActiveTurnChange,
}: UseChatAutoFollowOptions): UseChatAutoFollowResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const [containerEl, setContainerEl] = React.useState<HTMLDivElement | null>(null);
const lastSeenContainerRef = React.useRef<HTMLDivElement | null>(null);
const [state, setState] = React.useState<AutoFollowState>('following');
const [isOverflowing, setIsOverflowing] = React.useState(false);
const [showScrollButton, setShowScrollButton] = React.useState(false);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// `stateRef` is the single source of truth for follow vs released; the React
// state above is a mirror for rendering. `released` means the user scrolled
// up and away from the bottom.
const stateRef = React.useRef<AutoFollowState>('following');
const isMobileRef = React.useRef(isMobile);
isMobileRef.current = isMobile;
const sessionIsWorkingRef = React.useRef(sessionIsWorking);
sessionIsWorkingRef.current = sessionIsWorking;
// `settling` keeps passive follow alive for a short window after work stops
// so the final content can land at the bottom.
const settlingRef = React.useRef(false);
const settleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const currentSessionKeyRef = React.useRef(currentSessionKey);
currentSessionKeyRef.current = currentSessionKey;
const lastSessionKeyRef = React.useRef<string | null>(null);
// Programmatic-scroll marker: the bottom position we last
// wrote and when. A scroll event whose scrollTop matches `top` within a few
// px while still inside the TTL is OUR write, not the user's.
const autoRef = React.useRef<{ top: number; time: number } | null>(null);
const autoTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
// Timestamp until which a tracked height animation is in flight (see
// ANIMATION_GUARD_MS). 0 = no animation guard active.
const animationGuardUntilRef = React.useRef(0);
// True while the native (Capacitor iOS) keyboard slide choreography is in
// flight (between 'oc:keyboard-anim' and 'oc:keyboard-settled' from
// useNativeMobileChrome). During that window the pinned content is moved by a
// transform on the inner wrapper, so the ResizeObserver chase must stand down.
const keyboardAnimRef = React.useRef(false);
// Last observed scrollTop, used to derive scroll DIRECTION in the scroll
// handler so the bottom-zone re-engage only fires when arriving at the bottom
// by scrolling down — never when a user scrolling UP merely lands in the zone.
const lastScrollTopRef = React.useRef(0);
// Entry-stick window state (see ENTRY_STICK_* above).
const entryStickRef = React.useRef(false);
const entryStickQuietTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const entryStickCapTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const entryStickLastHeightRef = React.useRef(0);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
// When restoreSnapshot is invoked while ChatViewport is still hydrating
// (skeleton rendered, no scroll container yet), we record the session here
// so a follow-up effect can replay the restore once the container mounts.
const pendingInitialRestoreRef = React.useRef<string | null>(null);
const updateViewportAnchor = useViewportStore((s) => s.updateViewportAnchor);
// Detect when the scroll container DOM element changes (mount, unmount, remount).
// Without this, listener-attach effects would only ever bind to the element that
// existed at the hook's first render, missing later mounts (e.g. after first send
// promotes a draft session to a real chat with messages).
// eslint-disable-next-line react-hooks/exhaustive-deps
React.useLayoutEffect(() => {
if (scrollRef.current !== lastSeenContainerRef.current) {
lastSeenContainerRef.current = scrollRef.current;
setContainerEl(scrollRef.current);
}
});
// `active` is `working || settling`. Passive auto-follow
// (the ResizeObserver re-pin and any non-forced scrollToBottom) only runs
// while active. When the session is idle, content-size changes are layout
// churn — virtualizer re-measurement, async tool/code rendering — NOT live
// growth, so we must NOT yank the user to the bottom. Forcing this gate is
// what stops the twitch when tall items (expanded tools) re-measure as the
// user scrolls.
const isActive = React.useCallback((): boolean => {
return sessionIsWorkingRef.current || settlingRef.current;
}, []);
const setStateValue = React.useCallback((next: AutoFollowState) => {
if (stateRef.current === next) return;
stateRef.current = next;
setState(next);
}, []);
// ── auto marker ────────────────────────────────────────────────────────
const markAuto = React.useCallback((el: HTMLElement) => {
autoRef.current = {
top: Math.max(0, el.scrollHeight - el.clientHeight),
time: now(),
};
if (autoTimerRef.current) clearTimeout(autoTimerRef.current);
autoTimerRef.current = setTimeout(() => {
autoRef.current = null;
autoTimerRef.current = null;
}, AUTO_MARK_TTL_MS);
}, []);
const isAuto = React.useCallback((el: HTMLElement): boolean => {
const a = autoRef.current;
if (!a) return false;
if (now() - a.time > AUTO_MARK_TTL_MS) {
autoRef.current = null;
return false;
}
return Math.abs(el.scrollTop - a.top) < AUTO_MATCH_TOLERANCE_PX;
}, []);
const isAnimationGuardActive = React.useCallback((): boolean => {
return now() < animationGuardUntilRef.current;
}, []);
// ── entry-stick window ───────────────────────────────────────────────────
const endEntryStick = React.useCallback(() => {
entryStickRef.current = false;
if (entryStickQuietTimerRef.current) {
clearTimeout(entryStickQuietTimerRef.current);
entryStickQuietTimerRef.current = null;
}
if (entryStickCapTimerRef.current) {
clearTimeout(entryStickCapTimerRef.current);
entryStickCapTimerRef.current = null;
}
}, []);
// (Re)arm the quiescence timer: the window closes this long after the last
// growth. Called once on begin and again on every growth-driven re-pin.
const armEntryStickQuiet = React.useCallback(() => {
if (entryStickQuietTimerRef.current) {
clearTimeout(entryStickQuietTimerRef.current);
}
entryStickQuietTimerRef.current = setTimeout(() => {
entryStickQuietTimerRef.current = null;
endEntryStick();
}, ENTRY_STICK_QUIESCENCE_MS);
}, [endEntryStick]);
const beginEntryStick = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
entryStickRef.current = true;
entryStickLastHeightRef.current = el.scrollHeight;
armEntryStickQuiet();
// Reset the absolute cap fresh on every entry (e.g. session switch) so a
// stale cap from a previous open can't cut this window short.
if (entryStickCapTimerRef.current) {
clearTimeout(entryStickCapTimerRef.current);
}
entryStickCapTimerRef.current = setTimeout(() => {
entryStickCapTimerRef.current = null;
endEntryStick();
}, ENTRY_STICK_MAX_MS);
}, [armEntryStickQuiet, endEntryStick]);
// ── overflow / scroll-to-bottom button ──────────────────────────────────
const updateOverflowAndButton = React.useCallback(() => {
const container = scrollRef.current;
if (!container) {
setIsOverflowing(false);
setShowScrollButton(false);
return;
}
const overflowing = canScroll(container);
setIsOverflowing(overflowing);
if (!overflowing) {
setShowScrollButton(false);
return;
}
const showButton = stateRef.current === 'released' && !isNearBottom(container, isMobileRef.current);
setShowScrollButton(showButton);
}, []);
// ── core scroll primitives ───────────────────────────────────────────────
const scrollToBottomNow = React.useCallback((behavior: ScrollBehavior) => {
const el = scrollRef.current;
if (!el) return;
markAuto(el);
// `scrollHeight` is rounded to an integer while the real content height
// is fractional (prose line-heights), so `scrollTop = scrollHeight`
// leaves a 01px remainder that oscillates per streamed token and makes
// bottom-anchored rows jitter vertically. An over-large target clamps to
// the exact fractional maximum instead, pinning content to the bottom.
const overshootTarget = el.scrollHeight + 4096;
if (behavior === 'smooth') {
el.scrollTo({ top: overshootTarget, behavior });
return;
}
// Direct `scrollTop` assignment bypasses any CSS `scroll-behavior: smooth`
// and lands in the same frame — no visible catch-up animation.
el.scrollTop = overshootTarget;
}, [markAuto]);
// `force` true = user-intent jump (clears released and always scrolls).
// `force` false = passive follow (only while still following).
const scrollToBottom = React.useCallback((force: boolean, behavior: ScrollBehavior = 'auto') => {
const el = scrollRef.current;
// Passive follow only while active (working/settling). Forced jumps
// (send, go-to-bottom, session restore) always proceed.
if (!force && !isActive()) return;
if (force && stateRef.current !== 'following') {
setStateValue('following');
}
if (!el) return;
if (!force && stateRef.current !== 'following') return;
// Always re-pin, even when already within tolerance of the bottom.
// Sub-tolerance growth (fractional line-height remainders) would
// otherwise leave the bottom drifting by up to ±AUTO_MATCH_TOLERANCE_PX
// between full re-pins, which reads as 1px vertical jitter on
// bottom-anchored rows during streaming. The write happens pre-paint
// (ResizeObserver) and is a no-op when the position is unchanged.
scrollToBottomNow(force ? behavior : 'auto');
}, [isActive, scrollToBottomNow, setStateValue]);
// User left the bottom — release auto-follow.
const stop = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
if (!canScroll(el)) {
setStateValue('following');
return;
}
if (stateRef.current === 'released') return;
setStateValue('released');
updateOverflowAndButton();
}, [setStateValue, updateOverflowAndButton]);
// ── public scroll API (mapped onto the primitives) ───────────────────────
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
scrollToBottom(true, mode === 'smooth' ? 'smooth' : 'auto');
}, [scrollToBottom]);
const scrollToBottomOnSend = React.useCallback(() => {
// Single movement to the just-sent message. Force re-pins to the bottom
// whether we were following or scrolled up; the content ResizeObserver
// keeps us pinned as the optimistic message and its reply stream in.
scrollToBottom(true);
}, [scrollToBottom]);
const releaseAutoFollow = React.useCallback(() => {
setStateValue('released');
updateOverflowAndButton();
}, [setStateValue, updateOverflowAndButton]);
const releaseFromUserIntent = React.useCallback(() => {
// A genuine user gesture (wheel/touch/key/scrollbar) cancels the entry
// window immediately so we never fight the user's read position.
endEntryStick();
stop();
}, [endEntryStick, stop]);
// ── per-session snapshot persistence (kept; restore still goes to bottom) ─
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const pending = pendingSaveRef.current;
if (!pending) return;
const container = scrollRef.current;
if (!container) {
pendingSaveRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
pendingSaveRef.current = null;
}, [updateViewportAnchor]);
const queueSave = React.useCallback(() => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
const container = scrollRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const anchorRatio = scrollHeight > 0
? (scrollTop + clientHeight / 2) / scrollHeight
: 0;
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
pendingSaveRef.current = { sessionId, anchor };
if (saveTimerRef.current !== null) return;
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
flushSave();
}, SAVE_DEBOUNCE_MS);
}, [flushSave]);
const saveSnapshotNow = React.useCallback(() => {
flushSave();
}, [flushSave]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionKey = currentSessionKeyRef.current;
if (!sessionKey) return false;
const container = scrollRef.current;
if (!container) {
// ChatViewport not mounted yet (e.g., session still hydrating).
// Record the request so the container-attach effect can replay it.
pendingInitialRestoreRef.current = sessionKey;
setStateValue('following');
return false;
}
pendingInitialRestoreRef.current = null;
// Always return to the bottom on session switch. The content
// ResizeObserver re-pins instantly as late
// history measures in, so there is no smooth scroll-from-mid artifact.
setStateValue('following');
scrollToBottom(true);
// Hold the bottom across late async growth (e.g. task/subagent child
// session data landing a beat after entry) until content quiesces or the
// user scrolls.
beginEntryStick();
updateOverflowAndButton();
return false;
}, [beginEntryStick, scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── session change ───────────────────────────────────────────────────────
React.useEffect(() => {
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
return;
}
lastSessionKeyRef.current = currentSessionKey;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
flushSave();
autoRef.current = null;
// Drop any pending restore request inherited from a different session.
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current !== currentSessionKey) {
pendingInitialRestoreRef.current = null;
}
}, [currentSessionId, currentSessionKey, flushSave]);
// When work begins and we are still
// following, pin to the bottom. When work stops, keep following alive for a
// short settle window so the final content lands at the bottom, then go
// idle (after which passive follow is disabled — see `isActive`).
React.useEffect(() => {
settlingRef.current = false;
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
if (sessionIsWorking) {
if (stateRef.current === 'following') {
scrollToBottom(true);
}
return;
}
settlingRef.current = true;
settleTimerRef.current = setTimeout(() => {
settlingRef.current = false;
settleTimerRef.current = null;
}, SETTLE_MS);
}, [sessionIsWorking, scrollToBottom]);
// Suppress the overlay scrollbar thumb only while we are actively following a
// live stream (the thumb would otherwise jump on every instant re-pin). When
// idle or released the scrollbar behaves normally. Stable: changes only when
// follow-state or working-state flips, not on every frame.
React.useEffect(() => {
setIsFollowingProgrammatically(state === 'following' && sessionIsWorking);
}, [state, sessionIsWorking]);
// Replay a deferred restoreSnapshot once ChatViewport mounts.
// useLayoutEffect ensures scroll position is set before the browser paints,
// preventing a visible flash of content at the wrong scroll position.
React.useLayoutEffect(() => {
if (!containerEl) return;
if (pendingInitialRestoreRef.current && pendingInitialRestoreRef.current === currentSessionKey) {
void restoreSnapshot();
}
}, [containerEl, currentSessionKey, restoreSnapshot]);
// ── scroll event handling ────────────────────────────────────────────────
const handleScrollEvent = React.useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const previousTop = lastScrollTopRef.current;
lastScrollTopRef.current = el.scrollTop;
const scrollingDown = el.scrollTop > previousTop + 0.5;
updateOverflowAndButton();
if (!canScroll(el)) {
setStateValue('following');
return;
}
// Within the bottom zone → (re-)pin to following. This is how scrolling
// back DOWN to the bottom resumes auto-follow. Crucially, re-engage only
// when the user arrives by scrolling down (or is already following, or is
// essentially at the true bottom). A user scrolling UP that merely lands
// in the bottom spacer zone must NOT be yanked back into follow — that is
// the dead-zone fight that made small upward scrolls impossible while
// content streams.
if (isNearBottom(el, isMobileRef.current)) {
const atTrueBottom = distanceFromBottom(el) <= AUTO_MATCH_TOLERANCE_PX;
if (scrollingDown || stateRef.current === 'following' || atTrueBottom) {
setStateValue('following');
}
queueSave();
return;
}
// Our own geometry change (a programmatic write that landed at the bottom
// but where content grew between the write and this event, OR a tracked
// height animation in flight) — keep following, don't release.
if (stateRef.current === 'following' && (isAuto(el) || isAnimationGuardActive())) {
scrollToBottom(false);
queueSave();
return;
}
// Genuine user scroll away from the bottom.
stop();
queueSave();
}, [isAnimationGuardActive, isAuto, queueSave, scrollToBottom, setStateValue, stop, updateOverflowAndButton]);
React.useEffect(() => {
const container = containerEl;
if (!container) return;
lastScrollTopRef.current = container.scrollTop;
const handleWheel = (event: WheelEvent) => {
if (event.deltaY >= 0) return;
if (nestedScrollableCanConsumeUp(container, event.target)) return;
releaseFromUserIntent();
};
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
const touch = event.touches.item(0);
touchLastY = touch ? touch.clientY : null;
};
const handleTouchMove = (event: TouchEvent) => {
const touch = event.touches.item(0);
if (!touch) {
touchLastY = null;
return;
}
const previousY = touchLastY;
touchLastY = touch.clientY;
if (previousY === null) return;
const fingerDelta = touch.clientY - previousY;
if (fingerDelta <= TOUCH_FINGER_DOWN_THRESHOLD) return;
if (nestedScrollableCanConsumeUp(container, event.target)) return;
releaseFromUserIntent();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!isReleaseKey(event)) return;
releaseFromUserIntent();
};
const handlePointerDownIntent = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Element)) return;
if (!target.closest('[data-overlay-scrollbar-thumb]')) return;
releaseFromUserIntent();
};
container.addEventListener('scroll', handleScrollEvent, { passive: true });
container.addEventListener('wheel', handleWheel, { passive: true });
container.addEventListener('touchstart', handleTouchStart, { passive: true });
container.addEventListener('touchmove', handleTouchMove, { passive: true });
container.addEventListener('touchend', handleTouchEnd, { passive: true });
container.addEventListener('touchcancel', handleTouchEnd, { passive: true });
container.addEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', handlePointerDownIntent, true);
}
return () => {
container.removeEventListener('scroll', handleScrollEvent);
container.removeEventListener('wheel', handleWheel);
container.removeEventListener('touchstart', handleTouchStart);
container.removeEventListener('touchmove', handleTouchMove);
container.removeEventListener('touchend', handleTouchEnd);
container.removeEventListener('touchcancel', handleTouchEnd);
container.removeEventListener('keydown', handleKeyDown);
if (typeof window !== 'undefined') {
window.removeEventListener('pointerdown', handlePointerDownIntent, true);
}
};
}, [containerEl, handleScrollEvent, releaseFromUserIntent]);
// The heart of the follow behaviour: the content ResizeObserver fires after
// layout and before paint, so re-pinning to the bottom here is invisible —
// there is no "jump up then catch up". Observe both the container (composer
// growth shrinks the viewport) and the inner content (streaming growth).
React.useEffect(() => {
const container = containerEl;
if (!container || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
// Keyboard slide in flight: the container/composer resizes it reports
// are part of the transform choreography — the settle handler does the
// single deterministic re-pin, so chasing here would just fight it.
if (keyboardAnimRef.current) {
updateOverflowAndButton();
return;
}
const el = scrollRef.current;
if (el && !canScroll(el)) {
setStateValue('following');
updateOverflowAndButton();
return;
}
updateOverflowAndButton();
// Entry-stick window: on first session open, FORCE the bottom on
// every growth so late async data (task/subagent child rows, code
// highlight, mermaid) can't strand the viewport mid-history. Force
// overrides any false `released` from the growth itself; only a real
// user gesture clears the window (releaseFromUserIntent).
if (entryStickRef.current && el) {
const grew = el.scrollHeight > entryStickLastHeightRef.current + 1;
entryStickLastHeightRef.current = el.scrollHeight;
scrollToBottom(true);
if (grew) armEntryStickQuiet();
return;
}
// Idle resize = layout churn (virtualizer re-measurement, async
// tool/code rendering), NOT live growth. Never re-pin when idle, or
// tall items re-measuring as the user scrolls cause an endless
// scroll-to-bottom/re-measure twitch.
if (!isActive()) return;
if (stateRef.current !== 'following') return;
scrollToBottom(false);
});
observer.observe(container);
const inner = container.firstElementChild;
if (inner instanceof Element) {
observer.observe(inner);
}
return () => observer.disconnect();
}, [armEntryStickQuiet, containerEl, isActive, scrollToBottom, setStateValue, updateOverflowAndButton]);
// ── native keyboard transitions (Capacitor choreography) ────────────────
// The chat scroller gets NO transforms during the keyboard transition:
// transforming the scroll container (or its content) forces WebKit to
// rebuild the composited scrolling layers, which stalls for seconds on
// long chats. Instead the chat repositions with instant snaps that hide
// behind the keyboard itself:
// show: content stays put while the keyboard/composer slide over it; the
// settled event (shell layout snap) does ONE instant re-pin.
// hide: the shell layout is restored up-front — the scrollTop clamp
// happens while the keyboard still covers that region — and the
// settled event re-pins once at the end.
// During the window we only guard the scroll heuristics and the observer
// chase. These events never fire outside the Capacitor app.
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handleKeyboardAnim = (event: Event) => {
const detail = (event as CustomEvent<{ phase: 'show' | 'hide'; slide: number; durationMs: number; easing: string }>).detail;
if (!detail) return;
keyboardAnimRef.current = true;
// The clamp/resize during the choreography can dispatch scroll events
// that land away from the auto marker — never read those as a user
// scroll-away.
animationGuardUntilRef.current = now() + detail.durationMs + ANIMATION_GUARD_MS;
};
const handleKeyboardSettled = () => {
keyboardAnimRef.current = false;
const el = scrollRef.current;
if (!el) {
updateOverflowAndButton();
return;
}
// Single deterministic re-pin, same task as the layout swap → lands
// before paint. (scrollToBottomNow, not scrollToBottom: this must not
// be gated on working/settling — the keyboard resize is a viewport
// change, not content growth.)
if (stateRef.current === 'following' && canScroll(el)) {
scrollToBottomNow('auto');
}
updateOverflowAndButton();
};
window.addEventListener('oc:keyboard-anim', handleKeyboardAnim);
window.addEventListener('oc:keyboard-settled', handleKeyboardSettled);
return () => {
window.removeEventListener('oc:keyboard-anim', handleKeyboardAnim);
window.removeEventListener('oc:keyboard-settled', handleKeyboardSettled);
keyboardAnimRef.current = false;
};
}, [scrollToBottomNow, updateOverflowAndButton]);
React.useEffect(() => {
updateOverflowAndButton();
}, [sessionMessageCount, updateOverflowAndButton]);
const notifyContentChange = React.useCallback((reason?: ContentChangeReason) => {
// A tracked height animation (e.g. Thinking auto-collapse) opens a guard
// window so its transient geometry / async scroll events are not misread
// as a user scroll-away. Real gestures still release through
// releaseFromUserIntent, so the user can always scroll up freely.
if (reason === 'animation') {
animationGuardUntilRef.current = now() + ANIMATION_GUARD_MS;
}
updateOverflowAndButton();
// Entry-stick window: late structural growth (notably the task/subagent
// summary landing from the child session — ToolPart emits 'structural'
// here) must keep us pinned and refresh the quiescence timer, even though
// the session is idle.
if (entryStickRef.current) {
scrollToBottom(true);
armEntryStickQuiet();
return;
}
if (stateRef.current === 'following') {
scrollToBottom(false);
}
}, [armEntryStickQuiet, scrollToBottom, updateOverflowAndButton]);
const animationHandlersRef = React.useRef<Map<string, AnimationHandlers>>(new Map());
const getAnimationHandlers = React.useCallback((messageId: string): AnimationHandlers => {
const cached = animationHandlersRef.current.get(messageId);
if (cached) return cached;
const kick = () => {
if (stateRef.current === 'following') {
scrollToBottom(false);
}
};
const handlers: AnimationHandlers = {
onChunk: kick,
onComplete: () => {
updateOverflowAndButton();
},
onStreamingCandidate: () => {},
onAnimationStart: () => {},
onAnimatedHeightChange: kick,
onReservationCancelled: () => {},
onReasoningBlock: () => {},
};
animationHandlersRef.current.set(messageId, handlers);
return handlers;
}, [scrollToBottom, updateOverflowAndButton]);
React.useEffect(() => {
return () => {
if (autoTimerRef.current) {
clearTimeout(autoTimerRef.current);
autoTimerRef.current = null;
}
if (settleTimerRef.current) {
clearTimeout(settleTimerRef.current);
settleTimerRef.current = null;
}
endEntryStick();
flushSave();
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [endEntryStick, flushSave]);
React.useEffect(() => {
if (!onActiveTurnChange) return;
const container = containerEl;
if (!container) return;
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) return;
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
if (elementByTurnId.get(turnId) !== node) return false;
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) collected.push(node);
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) changed = true;
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) changed = true;
});
});
});
if (changed) spy.markDirty();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const onScroll = () => spy.onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [containerEl, onActiveTurnChange]);
return {
scrollRef,
state,
isPinned: state === 'following',
isOverflowing,
isFollowingProgrammatically,
showScrollButton,
notifyContentChange,
getAnimationHandlers,
goToBottom,
scrollToBottomOnSend,
releaseAutoFollow,
saveSnapshotNow,
restoreSnapshot,
};
};
@@ -0,0 +1,867 @@
import React from 'react';
import { MessageFreshnessDetector } from '@/lib/messageFreshness';
import { createScrollSpy } from '@/components/chat/lib/scroll/scrollSpy';
import { useViewportStore } from '@/sync/viewport-store';
import { useUIStore } from '@/stores/useUIStore';
import {
CHAT_LIST_ANCHOR_OFFSET,
getAnchoredTurnMetrics,
resolveTimelineIsAtEnd,
type TimelineListMeasurementState,
type TimelineScrollMode,
} from '@/components/chat/lib/scroll/timelineScrollAnchoring';
// ──────────────────────────────────────────────────────────────────────────
// Chat timeline scroll ownership.
//
// The virtualized list owns the scroll position; this hook only decides which
// of three mutually exclusive modes is active and, when a mode calls for it,
// issues ONE deterministic scroll command:
//
// • `following-end` — pinned to the live edge. The list keeps us there
// through `maintainScrollAtEnd`; we only re-assert after a data change.
// • `anchoring-new-turn` — the just-sent user message is parked near the TOP
// of the viewport and the reply streams into the reserved end space below
// it. The viewport does NOT move while the turn still fits; once the turn
// outgrows the usable viewport we scroll by the exact delta needed to keep
// its end visible.
// • `free-scrolling` — the user took over. Nothing moves until they opt
// back in by returning to the end.
//
// Opting out of automatic movement is driven by REAL gestures (wheel /
// touchmove / pointerdown), not by inferring intent from scroll positions. Each
// gesture bumps a generation counter; any in-flight automatic movement compares
// its captured generation against the current one and aborts if they differ.
// That comparison replaces the timer windows the previous implementation needed
// to tell its own writes apart from the user's, which is why there are no
// guard/settle/entry-stick timers here.
// ──────────────────────────────────────────────────────────────────────────
// The subset of the list ref this hook drives. Declared structurally so the
// hook stays testable without a renderer and does not hard-depend on the list
// implementation.
export interface TimelineListHandle {
getState: () => TimelineListMeasurementState & {
readonly scroll: number;
readonly listen?: (
listenerType: 'totalSize',
callback: (value: number) => void,
) => () => void;
};
getScrollableNode: () => HTMLElement | null;
scrollToEnd: (options?: { animated?: boolean }) => unknown;
scrollToOffset: (params: { offset: number; animated?: boolean }) => unknown;
scrollToIndex: (params: {
index: number;
animated?: boolean;
viewPosition?: number;
viewOffset?: number;
}) => unknown;
}
interface UseChatTimelineScrollOptions {
currentSessionId: string | null;
currentSessionKey: string | null;
sessionMessageCount: number;
composerOverlayHeight: number;
// Id of the newest user message in the rendered timeline. When a send has
// armed the anchor, the next new id here becomes the anchored row.
lastUserMessageId: string | null;
onActiveTurnChange?: (turnId: string | null) => void;
}
export interface UseChatTimelineScrollResult {
scrollRef: React.RefObject<HTMLDivElement | null>;
// The live scroll element, as state, so effects that must re-bind when the
// list remounts (session switch) can depend on it.
scrollNode: HTMLDivElement | null;
isPinned: boolean;
registerList: (list: TimelineListHandle | null) => void;
anchorMessageId: string | null;
onAnchorReady: (messageId: string, anchorIndex: number) => void;
onAnchorSizeChanged: (messageId: string) => void;
onIsAtEndChange: (isAtEnd: boolean) => void;
onManualNavigation: () => void;
onTimelineDataChange: () => void;
showScrollButton: boolean;
/** A real gesture took the scroll; flips back on any explicit opt-in. */
userOwnsScroll: boolean;
isFollowingProgrammatically: boolean;
goToBottom: (mode?: 'instant' | 'smooth') => void;
scrollToBottomOnSend: () => void;
saveSnapshotNow: () => void;
restoreSnapshot: () => Promise<boolean>;
}
// Showing the pill is debounced so it does not flash while a thread switch
// settles (the list reports isAtEnd=false until its initial end-scroll lands).
// Hiding is always immediate.
const SHOW_SCROLL_BUTTON_DELAY_MS = 150;
const SAVE_DEBOUNCE_MS = 150;
// The anchor scroll is animated; `scrollend` is the authoritative completion
// signal, and this bounds the wait for browsers that drop it.
const ANCHOR_SETTLE_FALLBACK_MS = 750;
// Re-running the anchor positioning while the list is still mounting rows.
const ANCHOR_POSITION_ATTEMPTS = 12;
// Anchor restores only correct sub-pixel drift; anything larger is the user or
// a genuine relayout and must not be undone.
const ANCHOR_RESTORE_TOLERANCE_PX = 2;
export const useChatTimelineScroll = ({
currentSessionId,
currentSessionKey,
sessionMessageCount,
composerOverlayHeight,
lastUserMessageId,
onActiveTurnChange,
}: UseChatTimelineScrollOptions): UseChatTimelineScrollResult => {
const scrollRef = React.useRef<HTMLDivElement | null>(null);
const listRef = React.useRef<TimelineListHandle | null>(null);
const [scrollNode, setScrollNode] = React.useState<HTMLDivElement | null>(null);
const [anchorMessageId, setAnchorMessageId] = React.useState<string | null>(null);
const [showScrollButton, setShowScrollButton] = React.useState(false);
// "Pinned" is the live edge, which history pagination uses to decide whether
// it may load older pages without disturbing the read position.
const [isPinned, setIsPinned] = React.useState(true);
const [isFollowingProgrammatically, setIsFollowingProgrammatically] = React.useState(false);
// True after a real gesture until an explicit opt back in; drives the
// overlay scrollbar suppression instead of the anchor's mere existence.
const [userOwnsScroll, setUserOwnsScroll] = React.useState(false);
const modeRef = React.useRef<TimelineScrollMode>('following-end');
const isAtEndRef = React.useRef(true);
// Incremented by every real user gesture. Automatic movement is only valid
// while `liveFollowGenerationRef` still equals it.
const userGenerationRef = React.useRef(0);
const liveFollowGenerationRef = React.useRef<number | null>(0);
// Anchor lifecycle: armed on send → pending until the row exists → positioned
// while the animated scroll runs → settled once it has come to rest.
const armedForNextUserMessageRef = React.useRef(false);
const pendingAnchorRef = React.useRef<string | null>(null);
const positionedAnchorRef = React.useRef<string | null>(null);
const settledAnchorRef = React.useRef<string | null>(null);
const activeAnchorIndexRef = React.useRef<number | null>(null);
const pendingAnchorRestoreRef = React.useRef<{
readonly messageId: string;
readonly offset: number;
readonly userGeneration: number;
} | null>(null);
const anchorRestoreFrameRef = React.useRef<number | null>(null);
const showButtonTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const composerOverlayHeightRef = React.useRef(composerOverlayHeight);
composerOverlayHeightRef.current = composerOverlayHeight;
const sessionMessageCountRef = React.useRef(sessionMessageCount);
sessionMessageCountRef.current = sessionMessageCount;
const currentSessionIdRef = React.useRef(currentSessionId);
currentSessionIdRef.current = currentSessionId;
const currentSessionKeyRef = React.useRef(currentSessionKey);
currentSessionKeyRef.current = currentSessionKey;
const updateViewportAnchor = useViewportStore((state) => state.updateViewportAnchor);
const cancelShowButtonTimer = React.useCallback(() => {
if (showButtonTimerRef.current !== null) {
clearTimeout(showButtonTimerRef.current);
showButtonTimerRef.current = null;
}
}, []);
const hideScrollButton = React.useCallback(() => {
cancelShowButtonTimer();
setShowScrollButton(false);
}, [cancelShowButtonTimer]);
const scheduleShowScrollButton = React.useCallback(() => {
if (showButtonTimerRef.current !== null) return;
showButtonTimerRef.current = setTimeout(() => {
showButtonTimerRef.current = null;
setShowScrollButton(true);
}, SHOW_SCROLL_BUTTON_DELAY_MS);
}, []);
const clearAnchor = React.useCallback(() => {
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
setAnchorMessageId(null);
}, []);
// A real gesture: stop every automatic movement until the user opts back
// in. The anchored END SPACE stays — collapsing it mid-gesture clamps the
// viewport back to the end — only the anchor machinery is disarmed.
const onManualNavigation = React.useCallback(() => {
userGenerationRef.current += 1;
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
setUserOwnsScroll(true);
// The end may already have been left by our own movement, in which
// case no further at-end transition will fire — and while an animated
// follow glide trails the live edge, isAtEndRef is deliberately not
// updated, so measure the real distance instead of trusting it. This
// is an explicit gesture — show the pill immediately, no debounce.
const listState = listRef.current?.getState();
const atEndNow = (listState ? resolveTimelineIsAtEnd(listState) : undefined) ?? isAtEndRef.current;
isAtEndRef.current = atEndNow;
if (!atEndNow) {
cancelShowButtonTimer();
setShowScrollButton(true);
}
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
pendingAnchorRestoreRef.current = null;
if (anchorRestoreFrameRef.current !== null) {
cancelAnimationFrame(anchorRestoreFrameRef.current);
anchorRestoreFrameRef.current = null;
}
}, [cancelShowButtonTimer]);
const isLiveFollowActive = React.useCallback(() => (
liveFollowGenerationRef.current === userGenerationRef.current
), []);
// ── snapshot persistence ────────────────────────────────────────────────
const pendingSaveRef = React.useRef<{ sessionId: string; anchor: number } | null>(null);
const saveTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const flushSave = React.useCallback(() => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
const pending = pendingSaveRef.current;
if (!pending) return;
const container = scrollRef.current;
if (!container) {
pendingSaveRef.current = null;
return;
}
updateViewportAnchor(pending.sessionId, pending.anchor, {
scrollTop: container.scrollTop,
scrollHeight: container.scrollHeight,
clientHeight: container.clientHeight,
});
pendingSaveRef.current = null;
}, [updateViewportAnchor]);
const queueSave = React.useCallback(() => {
const sessionId = currentSessionIdRef.current;
if (!sessionId) return;
const container = scrollRef.current;
if (!container) return;
const { scrollTop, scrollHeight, clientHeight } = container;
const anchorRatio = scrollHeight > 0
? (scrollTop + clientHeight / 2) / scrollHeight
: 0;
const anchor = Math.floor(anchorRatio * sessionMessageCountRef.current);
pendingSaveRef.current = { sessionId, anchor };
if (saveTimerRef.current !== null) return;
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
flushSave();
}, SAVE_DEBOUNCE_MS);
}, [flushSave]);
const saveSnapshotNow = React.useCallback(() => {
flushSave();
}, [flushSave]);
// ── scroll commands ─────────────────────────────────────────────────────
const goToBottomReassertTimersRef = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
const clearGoToBottomReasserts = React.useCallback(() => {
for (const timer of goToBottomReassertTimersRef.current) clearTimeout(timer);
goToBottomReassertTimersRef.current = [];
}, []);
const goToBottom = React.useCallback((mode: 'instant' | 'smooth' = 'instant') => {
isAtEndRef.current = true;
setIsPinned(true);
setUserOwnsScroll(false);
modeRef.current = 'following-end';
// Returning to the end is an explicit opt back IN to live follow.
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: mode === 'smooth' });
// While a stream is growing the content, a single jump lands on the
// end as of that moment and the list's own follow may not have
// re-armed yet — re-assert a few times until the edge holds, then the
// library follows onward. A new user gesture invalidates the window.
clearGoToBottomReasserts();
const generation = userGenerationRef.current;
for (const delay of [150, 400, 800]) {
goToBottomReassertTimersRef.current.push(setTimeout(() => {
if (userGenerationRef.current !== generation) return;
if (modeRef.current !== 'following-end') return;
const state = listRef.current?.getState();
if (state && resolveTimelineIsAtEnd(state) === true) return;
void listRef.current?.scrollToEnd({ animated: false });
}, delay));
}
}, [clearAnchor, clearGoToBottomReasserts, hideScrollButton]);
// Sending arms the anchor. The message id is not known here (the optimistic
// row is created by the store), so the next new user message id claims it.
// Whether the send-time anchor positioning may animate. Sending from the
// live edge parks the new message with a short smooth scroll; sending
// from mid-history teleports — a long smooth scroll through the
// virtualized timeline gets cancelled by rows mounting and measuring
// along the way and dies partway there.
const anchorPositionInstantRef = React.useRef(false);
const scrollToBottomOnSend = React.useCallback(() => {
anchorPositionInstantRef.current = !isAtEndRef.current;
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'anchoring-new-turn';
liveFollowGenerationRef.current = userGenerationRef.current;
armedForNextUserMessageRef.current = true;
// The optimistic row is not committed yet; the next NEW user message id
// relative to this baseline claims the anchor, independent of whether
// the commit lands before or after this call.
armBaselineUserMessageIdRef.current = lastArmedUserMessageIdRef.current;
pendingAnchorRef.current = null;
positionedAnchorRef.current = null;
settledAnchorRef.current = null;
activeAnchorIndexRef.current = null;
hideScrollButton();
}, [hideScrollButton]);
// Claim the anchor as soon as the sent row exists in the timeline. The
// comparison is against the baseline captured when the send armed the
// anchor, so the claim works whether the optimistic row committed before
// or after the arming call.
const lastArmedUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
const armBaselineUserMessageIdRef = React.useRef<string | null>(lastUserMessageId);
React.useEffect(() => {
lastArmedUserMessageIdRef.current = lastUserMessageId;
if (!armedForNextUserMessageRef.current) return;
if (!lastUserMessageId || lastUserMessageId === armBaselineUserMessageIdRef.current) return;
armedForNextUserMessageRef.current = false;
pendingAnchorRef.current = lastUserMessageId;
setAnchorMessageId(lastUserMessageId);
}, [lastUserMessageId]);
const restoreSnapshot = React.useCallback(async (): Promise<boolean> => {
const sessionKey = currentSessionKeyRef.current;
if (!sessionKey) return false;
// Entering a session always returns to the live edge. Late async growth
// is handled by the list staying at the end, not by a timed hold.
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
void listRef.current?.scrollToEnd({ animated: false });
return false;
}, [clearAnchor, hideScrollButton]);
// ── list callbacks ──────────────────────────────────────────────────────
const registerList = React.useCallback((list: TimelineListHandle | null) => {
listRef.current = list;
const node = (list?.getScrollableNode() as HTMLDivElement | null) ?? null;
scrollRef.current = node;
setScrollNode(node);
}, []);
const onIsAtEndChange = React.useCallback((isAtEnd: boolean) => {
// While an automatic movement owns the viewport, leaving the end is our
// own doing (the anchored turn parks mid-timeline, the glide trails its
// target between corrections) — not a reason to offer the pill. Only a
// real gesture (free-scrolling) shows it.
if (!isAtEnd && isLiveFollowActive()) {
hideScrollButton();
return;
}
if (isAtEndRef.current === isAtEnd) return;
isAtEndRef.current = isAtEnd;
setIsPinned(isAtEnd);
if (isAtEnd) {
if (modeRef.current !== 'anchoring-new-turn') {
modeRef.current = 'following-end';
}
liveFollowGenerationRef.current = userGenerationRef.current;
setUserOwnsScroll(false);
hideScrollButton();
} else {
modeRef.current = 'free-scrolling';
liveFollowGenerationRef.current = null;
scheduleShowScrollButton();
}
queueSave();
}, [hideScrollButton, isLiveFollowActive, queueSave, scheduleShowScrollButton]);
// Park the anchored row near the top once the list has measured it.
const onAnchorReady = React.useCallback((messageId: string, anchorIndex: number) => {
// The anchored end space can be remeasured long after the send (turn
// completion, images decoding). Only the send-time anchoring mode may
// position the viewport.
if (modeRef.current !== 'anchoring-new-turn') return;
if (pendingAnchorRef.current === messageId) {
pendingAnchorRef.current = null;
}
activeAnchorIndexRef.current = anchorIndex;
if (positionedAnchorRef.current === messageId) return;
positionedAnchorRef.current = messageId;
settledAnchorRef.current = null;
const positionAnchor = (remainingAttempts: number) => {
requestAnimationFrame(() => {
if (positionedAnchorRef.current !== messageId) return;
const list = listRef.current;
if (!list) {
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
return;
}
const scrollNode = list.getScrollableNode();
if (!scrollNode) {
if (remainingAttempts > 0) positionAnchor(remainingAttempts - 1);
return;
}
let finished = false;
const finishPositioning = () => {
if (finished) return;
finished = true;
clearTimeout(fallbackTimer);
scrollNode.removeEventListener('scrollend', finishPositioning);
if (positionedAnchorRef.current !== messageId) return;
// Re-assert the resting offset without animation so the
// smooth scroll's own momentum cannot drift past it.
const scrollOffset = list.getState().scroll;
void list.scrollToOffset({ offset: scrollOffset, animated: false });
settledAnchorRef.current = messageId;
};
const fallbackTimer = setTimeout(finishPositioning, ANCHOR_SETTLE_FALLBACK_MS);
scrollNode.addEventListener('scrollend', finishPositioning, { once: true });
void list.scrollToIndex({
index: anchorIndex,
animated: !anchorPositionInstantRef.current,
viewPosition: 0,
viewOffset: CHAT_LIST_ANCHOR_OFFSET,
});
});
};
requestAnimationFrame(() => positionAnchor(ANCHOR_POSITION_ATTEMPTS));
}, []);
// The anchored row can still change height after it settles (an image
// decoding, a code block highlighting). Hold the resting offset, but only
// against sub-pixel drift and only while the user has not taken over.
const onAnchorSizeChanged = React.useCallback((messageId: string) => {
if (settledAnchorRef.current !== messageId) return;
if (!isLiveFollowActive()) return;
const scrollOffset = listRef.current?.getState().scroll;
if (scrollOffset === undefined) return;
if (pendingAnchorRestoreRef.current === null) {
pendingAnchorRestoreRef.current = {
messageId,
offset: scrollOffset,
userGeneration: userGenerationRef.current,
};
}
if (anchorRestoreFrameRef.current !== null) return;
anchorRestoreFrameRef.current = requestAnimationFrame(() => {
anchorRestoreFrameRef.current = null;
const pending = pendingAnchorRestoreRef.current;
pendingAnchorRestoreRef.current = null;
if (
!pending
|| settledAnchorRef.current !== pending.messageId
|| pending.userGeneration !== userGenerationRef.current
) {
return;
}
const list = listRef.current;
const currentOffset = list?.getState().scroll;
if (
typeof currentOffset === 'number'
&& Math.abs(currentOffset - pending.offset) <= ANCHOR_RESTORE_TOLERANCE_PX
) {
void list?.scrollToOffset({ offset: pending.offset, animated: false });
}
});
}, [isLiveFollowActive]);
// Whether the real rows (ignoring any reserved anchored end space) are tall
// enough to scroll. Without this, entering a short session would scroll into
// the reserved space and strand the content above the viewport.
const realContentOverflowsViewport = React.useCallback((list: TimelineListHandle): boolean => {
const state = list.getState();
if (state.data.length === 0) return false;
const lastIndex = state.data.length - 1;
const lastTop = state.positionAtIndex(lastIndex);
const lastHeight = state.sizeAtIndex(lastIndex);
if (
typeof lastTop !== 'number'
|| typeof lastHeight !== 'number'
|| !Number.isFinite(lastTop)
|| !Number.isFinite(lastHeight)
) {
return false;
}
const realContentBottom = lastTop + Math.max(1, lastHeight);
const visibleScrollLength = Math.max(
0,
state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET,
);
return realContentBottom > visibleScrollLength;
}, []);
// One deterministic correction per data change, two frames out so the list
// has measured the new rows. Nothing runs while the user owns the scroll.
const dataChangeFramesRef = React.useRef<{ first: number | null; second: number | null }>({
first: null,
second: null,
});
// User preference: with auto-follow off, streaming growth never moves the
// viewport — the anchored user message still parks at the top on send, but
// no glide or end-follow correction runs afterwards.
const streamingAutoFollowEnabled = useUIStore((state) => state.streamingAutoFollowEnabled);
const streamingAutoFollowEnabledRef = React.useRef(streamingAutoFollowEnabled);
streamingAutoFollowEnabledRef.current = streamingAutoFollowEnabled;
// While the list width is resizing, every pinning write fights the
// per-frame row re-measure and the pinned viewport shakes. Corrections
// stand down for the whole resize and the visible content is held by the
// list's size compensation instead. Deliberately NO snap back to the end
// afterwards: a slow drag settles repeatedly, and each snap reads as the
// very jump this suspension removes — geometry changed, staying where the
// reader is beats re-asserting the edge.
const widthResizingRef = React.useRef(false);
React.useEffect(() => {
if (!scrollNode || typeof ResizeObserver === 'undefined') return;
let lastWidth: number | null = null;
let quietTimer: ReturnType<typeof setTimeout> | null = null;
const observer = new ResizeObserver((observerEntries) => {
const width = observerEntries[observerEntries.length - 1]?.contentRect.width;
if (typeof width !== 'number') return;
if (lastWidth === null) {
lastWidth = width;
return;
}
if (Math.abs(width - lastWidth) < 1) return;
lastWidth = width;
widthResizingRef.current = true;
if (quietTimer !== null) clearTimeout(quietTimer);
quietTimer = setTimeout(() => {
quietTimer = null;
widthResizingRef.current = false;
}, 350);
});
observer.observe(scrollNode);
return () => {
observer.disconnect();
if (quietTimer !== null) clearTimeout(quietTimer);
};
}, [scrollNode]);
const onTimelineDataChange = React.useCallback(() => {
if (widthResizingRef.current) return;
if (!streamingAutoFollowEnabledRef.current) return;
if (!isLiveFollowActive()) return;
// Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content
// growth on its own — including a tail row growing in place — and
// releases when the user scrolls away. Following the end therefore
// needs no correction here; this handler only serves the
// anchored-turn glide below.
if (modeRef.current === 'following-end') return;
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
frames.first = requestAnimationFrame(() => {
frames.first = null;
frames.second = requestAnimationFrame(() => {
frames.second = null;
if (!isLiveFollowActive()) return;
// An anchor that exists but has not come to rest yet owns the
// viewport; correcting now would fight its animation.
if (pendingAnchorRef.current !== null) return;
if (
positionedAnchorRef.current !== null
&& settledAnchorRef.current !== positionedAnchorRef.current
) {
return;
}
const list = listRef.current;
if (!list) return;
if (modeRef.current === 'anchoring-new-turn') {
const anchorIndex = activeAnchorIndexRef.current;
if (anchorIndex === null) return;
const metrics = getAnchoredTurnMetrics({
state: list.getState(),
anchorIndex,
composerOverlayHeight: composerOverlayHeightRef.current,
anchorOffset: CHAT_LIST_ANCHOR_OFFSET,
});
// The turn still fits: leave the viewport exactly where the
// user is reading.
if (!metrics || metrics.scrollDeltaToRevealEnd <= 1) return;
// Animated: successive corrections restart the smooth scroll
// from the current position, so streaming reads as one
// continuous glide instead of a per-line hop. A real user
// gesture interrupts the native smooth scroll on its own.
void list.scrollToOffset({
offset: list.getState().scroll + metrics.scrollDeltaToRevealEnd,
animated: true,
});
return;
}
});
});
}, [isLiveFollowActive]);
// The streaming tail grows inside one row without changing the entries
// array, so data-change callbacks are silent for the entire stream. The
// list's total content size is the authoritative growth signal; every
// change re-runs the same guarded correction.
const onTimelineDataChangeRef = React.useRef(onTimelineDataChange);
onTimelineDataChangeRef.current = onTimelineDataChange;
React.useEffect(() => {
if (!scrollNode) return;
const listen = listRef.current?.getState().listen;
if (!listen) return;
const unsubscribe = listen('totalSize', () => {
onTimelineDataChangeRef.current();
});
return unsubscribe;
}, [scrollNode]);
// ── gesture opt-out ─────────────────────────────────────────────────────
const onManualNavigationRef = React.useRef(onManualNavigation);
onManualNavigationRef.current = onManualNavigation;
React.useEffect(() => {
if (!scrollNode) return;
// A gesture is meaningful when the viewport can move up AT ALL:
// either the real rows overflow the viewport, or there is scrolled
// history above (an anchored turn parks mid-conversation with
// reserved space below — the real rows may not overflow yet, but
// wheel-up is still a genuine opt-out; swallowing it left live-follow
// armed, which suppressed the pill and kept corrections armed under a
// viewport the user had taken).
const canScrollUp = () => {
const list = listRef.current;
if (!list) return false;
if (list.getState().scroll > 1) return true;
return realContentOverflowsViewport(list);
};
const gesture = () => {
onManualNavigationRef.current();
};
const handleWheel = (event: WheelEvent) => {
// Scrolling toward the end is not opting out of follow.
if (event.deltaY < 0 && canScrollUp()) gesture();
};
// Touch mirrors wheel by finger direction, not by having already left
// the end: while a stream keeps re-pinning the viewport, waiting for
// an at-end transition means the drag never registers — the user
// cannot scroll, the pill never appears, and live-follow stays armed
// under a viewport they are fighting for.
let touchLastY: number | null = null;
const handleTouchStart = (event: TouchEvent) => {
touchLastY = event.touches[0]?.clientY ?? null;
};
const handleTouchMove = (event: TouchEvent) => {
const y = event.touches[0]?.clientY ?? null;
const lastY = touchLastY;
touchLastY = y;
if (y === null) return;
// A downward finger drags the content up — the touch wheel-up.
const draggedUp = lastY !== null && y > lastY;
if ((draggedUp || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleTouchEnd = () => {
touchLastY = null;
};
const handlePointerDown = (event: PointerEvent) => {
// The scrollbar track is the scroll node itself; a tap on a row
// only breaks follow when the viewport already left the end.
if ((event.target === scrollNode || !isAtEndRef.current) && canScrollUp()) gesture();
};
const handleKeyDown = (event: KeyboardEvent) => {
if ((event.key === 'PageUp' || event.key === 'Home' || event.key === 'ArrowUp') && canScrollUp()) {
gesture();
}
};
const handleScroll = () => {
queueSave();
};
scrollNode.addEventListener('wheel', handleWheel, { passive: true });
scrollNode.addEventListener('touchstart', handleTouchStart, { passive: true });
scrollNode.addEventListener('touchmove', handleTouchMove, { passive: true });
scrollNode.addEventListener('touchend', handleTouchEnd, { passive: true });
scrollNode.addEventListener('touchcancel', handleTouchEnd, { passive: true });
scrollNode.addEventListener('pointerdown', handlePointerDown, { passive: true });
scrollNode.addEventListener('keydown', handleKeyDown);
scrollNode.addEventListener('scroll', handleScroll, { passive: true });
return () => {
scrollNode.removeEventListener('wheel', handleWheel);
scrollNode.removeEventListener('touchstart', handleTouchStart);
scrollNode.removeEventListener('touchmove', handleTouchMove);
scrollNode.removeEventListener('touchend', handleTouchEnd);
scrollNode.removeEventListener('touchcancel', handleTouchEnd);
scrollNode.removeEventListener('pointerdown', handlePointerDown);
scrollNode.removeEventListener('keydown', handleKeyDown);
scrollNode.removeEventListener('scroll', handleScroll);
};
}, [queueSave, realContentOverflowsViewport, scrollNode]);
// ── session lifecycle ───────────────────────────────────────────────────
const lastSessionKeyRef = React.useRef<string | null>(null);
React.useEffect(() => {
if (!currentSessionId || !currentSessionKey || currentSessionKey === lastSessionKeyRef.current) {
return;
}
lastSessionKeyRef.current = currentSessionKey;
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
// Persist the outgoing session's position before the new one takes over.
flushSave();
isAtEndRef.current = true;
setUserOwnsScroll(false);
modeRef.current = 'following-end';
liveFollowGenerationRef.current = userGenerationRef.current;
clearAnchor();
hideScrollButton();
}, [clearAnchor, currentSessionId, currentSessionKey, flushSave, hideScrollButton]);
// Suppress the overlay scrollbar thumb while automatic movement owns the
// scroll position, so it does not jump on each correction.
React.useEffect(() => {
setIsFollowingProgrammatically(!showScrollButton && !userOwnsScroll);
}, [showScrollButton, userOwnsScroll]);
React.useEffect(() => () => {
cancelShowButtonTimer();
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
if (anchorRestoreFrameRef.current !== null) cancelAnimationFrame(anchorRestoreFrameRef.current);
const frames = dataChangeFramesRef.current;
if (frames.first !== null) cancelAnimationFrame(frames.first);
if (frames.second !== null) cancelAnimationFrame(frames.second);
}, [cancelShowButtonTimer]);
// ── active-turn spy ─────────────────────────────────────────────────────
// Reads turn positions straight from the DOM, so it is unaffected by which
// list implementation owns the container. Rows mounting and unmounting
// during virtualized scrolling are tracked through the mutation observer.
React.useEffect(() => {
if (!onActiveTurnChange) return;
const container = scrollNode;
if (!container) return;
let lastActiveTurnId: string | null = null;
const spy = createScrollSpy({
onActive: (turnId) => {
if (turnId === lastActiveTurnId) return;
lastActiveTurnId = turnId;
onActiveTurnChange(turnId);
},
});
spy.setContainer(container);
const elementByTurnId = new Map<string, HTMLElement>();
const registerTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
elementByTurnId.set(turnId, node);
spy.register(node, turnId);
return true;
};
const unregisterTurnNode = (node: HTMLElement) => {
const turnId = node.dataset.turnId;
if (!turnId) return false;
if (elementByTurnId.get(turnId) !== node) return false;
elementByTurnId.delete(turnId);
spy.unregister(turnId);
return true;
};
const collectTurnNodes = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
const collected: HTMLElement[] = [];
if (node.matches('[data-turn-id]')) collected.push(node);
node.querySelectorAll<HTMLElement>('[data-turn-id]').forEach((el) => collected.push(el));
return collected;
};
container.querySelectorAll<HTMLElement>('[data-turn-id]').forEach(registerTurnNode);
spy.markDirty();
const mutationObserver = new MutationObserver((records) => {
let changed = false;
records.forEach((record) => {
record.removedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (unregisterTurnNode(turnNode)) changed = true;
});
});
record.addedNodes.forEach((node) => {
collectTurnNodes(node).forEach((turnNode) => {
if (registerTurnNode(turnNode)) changed = true;
});
});
});
if (changed) spy.markDirty();
});
mutationObserver.observe(container, { subtree: true, childList: true });
const onScroll = () => spy.onScroll();
container.addEventListener('scroll', onScroll, { passive: true });
return () => {
container.removeEventListener('scroll', onScroll);
mutationObserver.disconnect();
spy.destroy();
};
}, [onActiveTurnChange, scrollNode]);
return {
scrollRef,
scrollNode,
isPinned,
registerList,
anchorMessageId,
onAnchorReady,
onAnchorSizeChanged,
onIsAtEndChange,
onManualNavigation,
onTimelineDataChange,
showScrollButton,
userOwnsScroll,
isFollowingProgrammatically,
goToBottom,
scrollToBottomOnSend,
saveSnapshotNow,
restoreSnapshot,
};
};
+17 -17
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { isTerminalEventTarget } from '@/lib/terminalFocus';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs';
import { useSelectionStore } from '@/sync/selection-store';
import * as sessionActions from '@/sync/session-actions';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
@@ -59,7 +60,6 @@ export const useKeyboardShortcuts = () => {
}, [currentShortcutDirectory]);
const isMobile = useUIStore((s) => s.isMobile);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen);
const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen);
@@ -154,7 +154,6 @@ export const useKeyboardShortcuts = () => {
isAboutDialogOpen,
isMultiRunLauncherOpen,
isImagePreviewOpen,
activeMainTab,
isPromptNavigatorPanelOpen,
} = useUIStore.getState();
@@ -183,7 +182,7 @@ export const useKeyboardShortcuts = () => {
}
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen;
const isChatActive = activeMainTab === 'chat';
const isChatActive = true;
if (hasOverlay || !isChatActive) {
resetAbortPriming();
@@ -245,7 +244,6 @@ export const useKeyboardShortcuts = () => {
if (eventMatchesShortcut(e, combo('toggle_prompt_navigator'))) {
const {
activeMainTab,
promptNavigatorEnabled,
isSettingsDialogOpen,
isCommandPaletteOpen,
@@ -257,7 +255,7 @@ export const useKeyboardShortcuts = () => {
isImagePreviewOpen,
} = useUIStore.getState();
if (!promptNavigatorEnabled || isMobile || isVSCodeRuntime() || activeMainTab !== 'chat') {
if (!promptNavigatorEnabled || isMobile || isVSCodeRuntime()) {
return;
}
@@ -302,13 +300,20 @@ export const useKeyboardShortcuts = () => {
return;
}
if (!isVSCodeRuntime() && useUIStore.getState().sessionTabsEnabled && eventMatchesShortcut(e, combo('close_session_tab'))) {
e.preventDefault();
if (currentSessionId) {
closeSessionTabAndActivateNeighbour(currentSessionId);
}
return;
}
const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat'));
const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree'));
if (matchedNewSessionShortcut || matchedWorktreeShortcut) {
e.preventDefault();
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
if (!isVSCodeRuntime() && matchedWorktreeShortcut) {
@@ -394,11 +399,10 @@ export const useKeyboardShortcuts = () => {
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
} = useUIStore.getState();
const hasOverlay = isSettingsDialogOpen || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
if (hasOverlay || activeMainTab !== 'chat' || !isChatInputTarget(e.target)) {
if (hasOverlay || !isChatInputTarget(e.target)) {
return;
}
@@ -525,7 +529,6 @@ export const useKeyboardShortcuts = () => {
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
isModelSelectorOpen,
} = useUIStore.getState();
@@ -536,7 +539,7 @@ export const useKeyboardShortcuts = () => {
// Skip if any overlay open or not on chat tab
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
const isChatActive = true;
if (hasOverlay || !isChatActive) {
return;
@@ -555,7 +558,6 @@ export const useKeyboardShortcuts = () => {
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
} = useUIStore.getState();
if (isSettingsDialogOpen) {
@@ -563,7 +565,7 @@ export const useKeyboardShortcuts = () => {
}
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
const isChatActive = true;
if (hasOverlay || !isChatActive) {
return;
@@ -602,7 +604,6 @@ export const useKeyboardShortcuts = () => {
isHelpDialogOpen,
isSessionSwitcherOpen,
isAboutDialogOpen,
activeMainTab,
favoriteModels,
addRecentModel,
} = useUIStore.getState();
@@ -612,7 +613,7 @@ export const useKeyboardShortcuts = () => {
}
const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen;
const isChatActive = activeMainTab === 'chat';
const isChatActive = true;
if (hasOverlay || !isChatActive || favoriteModels.length === 0) {
return;
@@ -644,8 +645,8 @@ export const useKeyboardShortcuts = () => {
}
if (eventMatchesShortcut(e, combo('toggle_dictation'))) {
const { activeMainTab, isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, isSettingsDialogOpen } = useUIStore.getState();
if (activeMainTab !== 'chat' || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSettingsDialogOpen) {
const { isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, isSettingsDialogOpen } = useUIStore.getState();
if (isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSettingsDialogOpen) {
return;
}
e.preventDefault();
@@ -695,7 +696,6 @@ export const useKeyboardShortcuts = () => {
toggleTerminalSurfaceExpanded,
isMobile,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
setModelSelectorOpen,
setTimelineDialogOpen,
+3 -8
View File
@@ -102,7 +102,6 @@ export const useMenuActions = (
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen);
const setActiveMainTab = useUIStore((s) => s.setActiveMainTab);
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
@@ -151,10 +150,9 @@ export const useMenuActions = (
const nextSession = sessions[nextIndex];
if (!nextSession) return;
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
useSessionUIStore.getState().setCurrentSession(nextSession.id);
}, [setActiveMainTab, setSessionSwitcherOpen]);
}, [setSessionSwitcherOpen]);
const navigateProject = React.useCallback((direction: -1 | 1) => {
const { activeProjectId, projects, setActiveProject } = useProjectsStore.getState();
@@ -191,8 +189,7 @@ export const useMenuActions = (
break;
case 'new-session':
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
setSessionSwitcherOpen(false);
{
const sessionState = useSessionUIStore.getState();
const directory = useDirectoryStore.getState().currentDirectory;
@@ -203,8 +200,7 @@ export const useMenuActions = (
break;
case 'new-worktree-session':
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
setSessionSwitcherOpen(false);
createWorktreeSession();
break;
@@ -341,7 +337,6 @@ export const useMenuActions = (
onToggleMemoryDebug,
openNewSessionDraft,
setAboutDialogOpen,
setActiveMainTab,
setSessionSwitcherOpen,
setCommandPaletteOpen,
setSettingsDialogOpen,
+15 -25
View File
@@ -1,11 +1,11 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useUIStore } from '@/stores/useUIStore';
import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore';
import { parseRoute, updateBrowserURL, hasRouteParams } from '@/lib/router';
import type { RouteState, AppRouteState } from '@/lib/router';
import type { WorkspaceSurface } from '@/stores/useUIStore';
import { resolveSettingsSlug } from '@/lib/settings/metadata';
import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
/**
* Check if running in VS Code webview context.
@@ -49,7 +49,6 @@ export function useRouter(): void {
// Get store actions (stable references)
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const setActiveSurface = useUIStore((state) => state.setActiveSurface);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const navigateToDiff = useUIStore((state) => state.navigateToDiff);
@@ -88,9 +87,16 @@ export function useRouter(): void {
setSettingsDialogOpen(false);
}
// 3. Apply the view selected by the legacy URL parameter.
if (route.tab) {
setActiveSurface(route.tab);
// 3. Apply the view selected by the legacy URL parameter. Desktop
// surfaces live in the context panel, so a non-chat tab deep link
// opens the matching panel surface; activeSurface itself stays 'chat'
// (nothing renders non-chat surfaces in the main area).
if (route.tab && route.tab !== 'chat') {
const directory = useDirectoryStore.getState().currentDirectory;
if (directory) {
const mode: ContextPanelMode = route.tab === 'files' ? 'file' : route.tab;
useUIStore.getState().openContextSurface(directory, mode);
}
}
// 4. Apply diff file (only if going to diff tab)
@@ -101,7 +107,7 @@ export function useRouter(): void {
isApplyingRouteRef.current = false;
}
},
[setCurrentSession, setActiveSurface, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
[setCurrentSession, setSettingsDialogOpen, setSettingsPage, navigateToDiff]
);
/**
@@ -113,10 +119,8 @@ export function useRouter(): void {
return {
sessionId: sessionState.currentSessionId,
tab: uiState.activeSurface,
isSettingsOpen: uiState.isSettingsDialogOpen,
settingsPath: uiState.settingsPage,
diffFile: uiState.pendingDiffFile,
};
}, []);
@@ -162,9 +166,7 @@ export function useRouter(): void {
updateBrowserURL({
...getCurrentAppState(),
sessionId: route.sessionId ?? useSessionUIStore.getState().currentSessionId,
tab: route.tab ?? useUIStore.getState().activeSurface,
settingsPath: route.settingsPath ?? useUIStore.getState().settingsPage,
diffFile: route.diffFile ?? useUIStore.getState().pendingDiffFile,
}, { replace: true, force: true });
}
};
@@ -201,10 +203,8 @@ export function useRouter(): void {
return;
}
let prevSurface: WorkspaceSurface = useUIStore.getState().activeSurface;
let prevSettingsOpen: boolean = useUIStore.getState().isSettingsDialogOpen;
let prevSettingsPath: string = useUIStore.getState().settingsPage;
let prevDiffFile: string | null = useUIStore.getState().pendingDiffFile;
const unsubscribe = useUIStore.subscribe((state) => {
// Skip if we're currently applying a route
@@ -212,19 +212,13 @@ export function useRouter(): void {
return;
}
const surfaceChanged = state.activeSurface !== prevSurface;
const settingsOpenChanged = state.isSettingsDialogOpen !== prevSettingsOpen;
const settingsPathChanged = state.settingsPage !== prevSettingsPath;
const diffFileChanged = state.pendingDiffFile !== prevDiffFile && state.activeSurface === 'diff';
// Update tracking vars
prevSurface = state.activeSurface;
prevSettingsOpen = state.isSettingsDialogOpen;
prevSettingsPath = state.settingsPage;
prevDiffFile = state.pendingDiffFile;
// Only sync if something relevant changed
if (surfaceChanged || settingsOpenChanged || settingsPathChanged || diffFileChanged) {
if (settingsOpenChanged || settingsPathChanged) {
syncURLFromState();
}
});
@@ -252,10 +246,6 @@ export function useRouter(): void {
if (uiState.isSettingsDialogOpen) {
setSettingsDialogOpen(false);
}
// Reset to chat when no route view is specified.
if (uiState.activeSurface !== 'chat') {
setActiveSurface('chat');
}
}
};
@@ -264,5 +254,5 @@ export function useRouter(): void {
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, [applyRoute, isVSCode, isEmbeddedChat, setActiveSurface, setSettingsDialogOpen]);
}, [applyRoute, isVSCode, isEmbeddedChat, setSettingsDialogOpen]);
}
+125 -5
View File
@@ -258,7 +258,12 @@ div[data-chat-input-footer="true"] {
/* Scroll shadow (HeroUI) fallback styling to ensure visible gradients without the Tailwind plugin */
[data-scroll-shadow="true"] {
--scroll-shadow-size: var(--scroll-shadow-size, 48px);
/* A concrete default, not var(--scroll-shadow-size, 48px): a custom
property referencing itself is a cycle and computes to invalid, which
silently killed every mask below for consumers that don't set the
variable inline (the hook-based chat scroller). Inline styles from the
ScrollShadow component still override this. */
--scroll-shadow-size: 48px;
}
[data-scroll-shadow="true"][data-orientation="vertical"] {
@@ -888,15 +893,15 @@ html:not(.dark) .chat-scroll {
}
/* Hide the long active todo before it can collide with the changed-files summary. */
@container status-row (max-width: 38rem) {
.status-row__active-todo {
@container composer-status-bar (max-width: 38rem) {
.composer-status-bar__active-todo {
display: none;
}
}
/* Hide the secondary changed-files label on narrow mobile layouts. */
@container status-row (max-width: 30rem) {
.status-row__changed-label {
@container composer-status-bar (max-width: 30rem) {
.composer-status-bar__changed-label {
display: none;
}
}
@@ -1336,6 +1341,14 @@ html:not(.dark) .chat-scroll {
background: transparent !important;
}
/* The marked renderer uses its own code-body wrapper instead of Streamdown's. */
.markdown-content [data-md-code-body],
.markdown-content [data-md-code-body] pre,
.markdown-content [data-md-code-body] code,
.markdown-content [data-md-code-body] [data-md-code-lines] {
background: transparent !important;
}
.markdown-content [data-md-code-lines] {
display: block;
min-width: 100%;
@@ -1348,6 +1361,64 @@ html:not(.dark) .chat-scroll {
min-width: 100%;
}
/* First uncached open of a session shows a hydration skeleton; the real
timeline replacing it fades in once instead of popping. Cached session
switches never carry this class and stay instant. */
@keyframes oc-chat-hydration-reveal {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.oc-chat-hydration-reveal {
animation: oc-chat-hydration-reveal 180ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.oc-chat-hydration-reveal {
animation: none;
}
}
/* A block committed mid-stream enters with a fade and a gentle rise
compositor-only properties, deliberately nothing heavier: during a long
stream the follow scroll masks the entrance anyway, so the effect only
really shows on short replies, and those must not pay for a GPU filter.
Blocks committed in the same tick cascade via --oc-md-enter-delay set
inline by the renderer. */
@keyframes oc-md-block-enter {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.oc-md-block-enter {
animation: oc-md-block-enter 320ms cubic-bezier(0.22, 1, 0.36, 1) both;
animation-delay: var(--oc-md-enter-delay, 0ms);
}
@media (prefers-reduced-motion: reduce) {
.oc-md-block-enter {
animation: none;
}
}
/* While streaming defers the per-line gutter markup, hold its horizontal
footprint (2rem column + 0.75rem gap) so the finished pass only fills in
the numbers instead of shifting every code line. */
.markdown-content pre[data-md-gutter-reserved] > code {
display: block;
padding-left: 2.75rem;
}
.markdown-content [data-md-code-line-number] {
align-self: stretch;
padding-right: 0.75rem;
@@ -1781,3 +1852,52 @@ html.desktop-runtime [class*="cursor-pointer"] {
html.desktop-runtime .markdown-content [data-openchamber-file-link="true"] {
cursor: default;
}
/* Header session tabs: thin separators between inactive neighbours, hidden
around the active or hovered tab (mirrors the titlebar tab language). */
.session-tab-slot {
position: relative;
}
.session-tab-slot:not(:first-child):not([data-active='true'])::before {
content: '';
position: absolute;
top: 8px;
inset-inline-start: -2.75px;
width: 1.5px;
height: 12px;
border-radius: 9999px;
background: var(--border);
}
.session-tab-slot[data-active='true'] + .session-tab-slot::before,
.session-tab-slot:not([data-active='true']):hover::before,
.session-tab-slot:not([data-active='true']):hover + .session-tab-slot::before {
display: none;
}
/* Header session tabs scroller: never show a scrollbar (it shifts the header
content vertically); overflow is communicated by the edge fades. */
.session-tabs-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
}
.session-tabs-scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
/* Session tab titles: fade out instead of "..." the ellipsis reads as
clutter next to the tab's status dot and hover controls. Short titles
never reach the fade zone (the span spans the tab, not the text). The
fade belongs to the resting state only: while the tab is hovered or its
controls are open, the title clips hard so the smear never sits next to
the menu/close/rename controls. */
.session-tab-title {
-webkit-mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 14px), transparent);
}
.session-tab:hover .session-tab-title,
.session-tab[data-controls-open='true'] .session-tab-title {
-webkit-mask-image: none;
mask-image: none;
}
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
const focusChatInputCalls: number[] = [];
const pendingInputCalls: Array<{ text: string | null; mode?: string }> = [];
const activeMainTabCalls: string[] = [];
const activeSurfaceCalls: string[] = [];
const sessionSwitcherCalls: boolean[] = [];
const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = [];
@@ -41,8 +41,8 @@ mock.module('@/sync/input-store', () => ({
mock.module('@/stores/useUIStore', () => ({
useUIStore: {
getState: () => ({
setActiveMainTab: (tab: string) => {
activeMainTabCalls.push(tab);
setActiveSurface: (tab: string) => {
activeSurfaceCalls.push(tab);
},
setSessionSwitcherOpen: (open: boolean) => {
sessionSwitcherCalls.push(open);
@@ -86,7 +86,7 @@ const installSelectionEnvironment = (options: {
const clearCalls = () => {
focusChatInputCalls.length = 0;
pendingInputCalls.length = 0;
activeMainTabCalls.length = 0;
activeSurfaceCalls.length = 0;
sessionSwitcherCalls.length = 0;
codeMirrorDispatches.length = 0;
codeMirrorView = null;
@@ -262,7 +262,7 @@ describe('addSelectionToChat', () => {
installSelectionEnvironment({ activeElement: textarea });
expect(addSelectionToChat()).toBe(true);
expect(activeMainTabCalls).toEqual(['chat']);
expect(activeSurfaceCalls).toEqual(['chat']);
expect(sessionSwitcherCalls).toEqual([false]);
expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]);
@@ -290,7 +290,7 @@ describe('addSelectionToChat', () => {
expect(addSelectionToChat()).toBe(false);
expect(pendingInputCalls).toEqual([]);
expect(activeMainTabCalls).toEqual(['chat']);
expect(activeSurfaceCalls).toEqual(['chat']);
await Promise.resolve();
expect(focusChatInputCalls.length).toBe(1);
@@ -151,7 +151,6 @@ export const captureSelectionMarkdownForChat = (): string | null => {
export const addSelectionToChat = (): boolean => {
const markdown = captureSelectionMarkdownForChat();
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false);
if (markdown) {
+11
View File
@@ -80,8 +80,19 @@ export interface ForceKillOptions {
cwd?: string;
}
export interface TerminalServerSession {
sessionId: string;
cwd: string;
status: 'running' | 'exited';
createdAt: number | null;
}
export interface TerminalAPI {
listShells?(): Promise<TerminalShellOption[]>;
/** Server-side sessions for a working directory; absent on runtimes without a server terminal list. */
listSessions?(cwd: string): Promise<TerminalServerSession[]>;
/** Marks the sessions as active so the server's idle sweep does not reap terminals an open client still shows. */
touchSessions?(sessionIds: string[]): Promise<void>;
createSession(options: CreateTerminalOptions): Promise<TerminalSession>;
connect(sessionId: string, handlers: TerminalHandlers): Subscription;
sendInput(sessionId: string, input: string): Promise<void>;
@@ -7,6 +7,7 @@ import type { TerminalShell } from '@/lib/api/types';
type AppearanceSlice = {
showReasoningTraces: boolean;
streamingAutoFollowEnabled: boolean;
workStatusPanelEnabled: boolean;
workStatusHiddenSections: string[];
sessionRecapEnabled: boolean;
@@ -62,6 +63,7 @@ export const startAppearanceAutoSave = (): void => {
let previous: AppearanceSlice = {
showReasoningTraces: useUIStore.getState().showReasoningTraces,
streamingAutoFollowEnabled: useUIStore.getState().streamingAutoFollowEnabled,
workStatusPanelEnabled: useUIStore.getState().workStatusPanelEnabled,
workStatusHiddenSections: useUIStore.getState().workStatusHiddenSections,
sessionRecapEnabled: useUIStore.getState().sessionRecapEnabled,
@@ -104,6 +106,7 @@ export const startAppearanceAutoSave = (): void => {
useUIStore.subscribe((state) => {
const current: AppearanceSlice = {
showReasoningTraces: state.showReasoningTraces,
streamingAutoFollowEnabled: state.streamingAutoFollowEnabled,
workStatusPanelEnabled: state.workStatusPanelEnabled,
workStatusHiddenSections: state.workStatusHiddenSections,
sessionRecapEnabled: state.sessionRecapEnabled,
@@ -156,6 +159,9 @@ export const startAppearanceAutoSave = (): void => {
if (current.showReasoningTraces !== previous.showReasoningTraces) {
diff.showReasoningTraces = current.showReasoningTraces;
}
if (current.streamingAutoFollowEnabled !== previous.streamingAutoFollowEnabled) {
diff.streamingAutoFollowEnabled = current.streamingAutoFollowEnabled;
}
if (current.sessionRecapEnabled !== previous.sessionRecapEnabled) {
diff.sessionRecapEnabled = current.sessionRecapEnabled;
}
+1 -1
View File
@@ -131,6 +131,7 @@ export type DesktopSettings = {
defaultVariant?: string;
defaultAgent?: string;
smallModelUseDefault?: boolean;
streamingAutoFollowEnabled?: boolean;
sessionRecapEnabled?: boolean;
sessionSuggestionEnabled?: boolean;
sessionGoalEnabled?: boolean;
@@ -179,7 +180,6 @@ export type DesktopSettings = {
collapsibleUserMessages?: boolean;
stickyUserHeader?: boolean;
promptNavigatorEnabled?: boolean;
expandedEditorToolbar?: boolean;
wideChatLayoutEnabled?: boolean;
showSplitAssistantMessageActions?: boolean;
fontSize?: number;
@@ -1845,9 +1845,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeRaw': 'Rohes Markdown',
'settings.voice.page.field.ttsInputModeSummarized': 'zusammengefasst',
'settings.openchamber.visual.section.colorMode': 'Farbmodus',
'settings.openchamber.visual.section.mobileLayout': 'Mobiles Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Alt',
'settings.openchamber.visual.option.mobileLayout.new': 'Neu',
'settings.openchamber.visual.section.localization': 'Lokalisierung',
'settings.openchamber.visual.section.spacingAndLayout': 'Abstand & Layout',
'settings.openchamber.visual.section.navigation': 'Navigation',
@@ -1859,6 +1856,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Werkzeuge standardmäßig geöffnet anzeigen:',
'settings.openchamber.visual.section.sessionAssistance': 'Sitzungshilfe',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Neuen Inhalten beim Streaming folgen',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Neuen Inhalten automatisch folgen, während eine Antwort gestreamt wird',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'Während eine Antwort eintrifft, folgt die Ansicht laufend dem neuesten Inhalt. Deaktivieren, um die Ansicht ruhig zu halten und manuell zu scrollen.',
'settings.openchamber.visual.section.messageAppearance': 'Nachrichten-Erscheinungsbild',
'settings.openchamber.visual.section.toolsAndFiles': 'Werkzeuge & Dateien',
'settings.openchamber.visual.section.composer': 'Komponist',
@@ -1920,6 +1921,10 @@ export const settingsDict = {
'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Versatz der Eingabeleiste zurücksetzen',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Schnelltasten des Terminals',
'settings.openchamber.visual.field.terminalQuickKeys': 'Schnelltasten des Terminals',
'settings.openchamber.visual.field.sessionTabsGroup': 'Sitzungs-Tabs',
'settings.openchamber.visual.field.sessionTabs': 'Sitzungen als Tabs in der Kopfzeile anzeigen',
'settings.openchamber.visual.field.sessionTabsAria': 'Sitzungs-Tabs in der Kopfzeile umschalten',
'settings.openchamber.visual.field.sessionTabsInfo': 'Geöffnete Sitzungen erscheinen als Tabs in der Kopfzeile. Ausgeschaltet zeigt die Kopfzeile wieder nur den Sitzungstitel.',
'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Esc, Strg, Pfeiltasten in der Terminalansicht anzeigen',
'settings.openchamber.visual.field.fileEditorKeymap': 'Tastaturlayout für Datei-Editor',
'settings.openchamber.visual.option.fileEditorKeymap.default': 'Standard',
@@ -1952,8 +1957,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.stickyUserHeader': 'Fixierter Benutzerkopf',
'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Prompt-Navigator',
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt-Navigator',
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Editor-Werkzeugleiste immer anzeigen',
'settings.openchamber.visual.field.expandedEditorToolbar': 'Editor-Werkzeugleiste immer anzeigen (unter den Datei-Reitern angeheftet)',
'settings.openchamber.visual.field.wideChatLayoutAria': 'Breites Chat-Layout',
'settings.openchamber.visual.field.wideChatLayout': 'Breites Chat-Layout',
'settings.openchamber.visual.field.codeBlockLineWrapAria': 'Codeblock-Zeilen umbrechen',
+4
View File
@@ -419,6 +419,10 @@ export const dict = {
'sessions.sidebar.activity.chatsEmpty': 'Noch keine Chats.',
'chat.chatInput.chooseProject': 'Projekt auswählen',
'sessions.switcher.openAria': 'Sitzungswechsler öffnen',
'header.sessionTabs.stripAria': 'Offene Sitzungen',
'header.sessionTabs.tabMenuAria': 'Aktionen für den Sitzungs-Tab',
'header.sessionTabs.closeTab': 'Tab schließen',
'header.sessionTabs.closeOtherTabs': 'Andere Tabs schließen',
'sessions.switcher.empty': 'Keine kürzlichen Sitzungen',
'sessions.switcher.draftTitle': 'Neue Sitzung',
'sessions.sidebar.updateCheck.errorTitle': 'Fehler beim Prüfen auf Aktualisierungen',
@@ -1913,9 +1913,6 @@ export const settingsDict = {
'settings.voice.page.field.ttsInputModeSummarized': 'summarized',
'settings.openchamber.visual.section.colorMode': 'Color Mode',
'settings.openchamber.visual.section.colorModeAndTheme': 'Color mode & Theme',
'settings.openchamber.visual.section.mobileLayout': 'Mobile Layout',
'settings.openchamber.visual.option.mobileLayout.default': 'Old',
'settings.openchamber.visual.option.mobileLayout.new': 'New',
'settings.openchamber.visual.section.localization': 'Localization',
'settings.openchamber.visual.section.spacingAndLayout': 'Spacing & Layout',
'settings.openchamber.visual.section.densityAndType': 'Density & type',
@@ -1932,6 +1929,10 @@ export const settingsDict = {
'settings.openchamber.visual.section.showToolsOpenedByDefault': 'Show tools opened by default',
'settings.openchamber.visual.section.sessionAssistance': 'Session Assistance',
'settings.openchamber.visual.section.reasoning': 'Reasoning',
'settings.openchamber.visual.section.streaming': 'Streaming',
'settings.openchamber.visual.field.streamingAutoFollow': 'Follow new content while streaming',
'settings.openchamber.visual.field.streamingAutoFollowAria': 'Automatically follow new content while a response streams',
'settings.openchamber.visual.field.streamingAutoFollowInfo': 'While a reply streams in, the view keeps gliding to the newest content. Turn this off to keep the view still and scroll manually.',
'settings.openchamber.visual.section.messageAppearance': 'Message Appearance',
'settings.openchamber.visual.section.toolsAndFiles': 'Tools & Files',
'settings.openchamber.visual.section.composer': 'Composer',
@@ -1998,6 +1999,10 @@ export const settingsDict = {
'settings.openchamber.visual.actions.resetInputBarOffsetAria': 'Reset input bar offset',
'settings.openchamber.visual.field.terminalQuickKeysAria': 'Terminal quick keys',
'settings.openchamber.visual.field.terminalQuickKeys': 'Terminal Quick Keys',
'settings.openchamber.visual.field.sessionTabsGroup': 'Session tabs',
'settings.openchamber.visual.field.sessionTabs': 'Show sessions as tabs in the header',
'settings.openchamber.visual.field.sessionTabsAria': 'Toggle session tabs in the header',
'settings.openchamber.visual.field.sessionTabsInfo': 'Sessions you open line up as tabs in the header. Turning this off restores the plain session title.',
'settings.openchamber.visual.field.terminalQuickKeysTooltip': 'Show Esc, Ctrl, Arrows in terminal view',
'settings.openchamber.visual.field.fileEditorKeymap': 'File editor keymap',
'settings.openchamber.visual.option.fileEditorKeymap.default': 'Default',
@@ -2030,8 +2035,6 @@ export const settingsDict = {
'settings.openchamber.visual.field.stickyUserHeader': 'Sticky User Header',
'settings.openchamber.visual.field.promptNavigatorEnabledAria': 'Prompt navigator',
'settings.openchamber.visual.field.promptNavigatorEnabled': 'Prompt Navigator',
'settings.openchamber.visual.field.expandedEditorToolbarAria': 'Always show editor toolbar',
'settings.openchamber.visual.field.expandedEditorToolbar': 'Always show editor toolbar (docked under the file tabs)',
'settings.openchamber.visual.field.autoSaveEnabledAria': 'Auto-save files',
'settings.openchamber.visual.field.autoSaveEnabled': 'Auto-save files',
'settings.openchamber.visual.field.autoSaveEnabledInfo': 'Automatically save file edits after you stop typing. Disable to require manual save.',

Some files were not shown because too many files have changed in this diff Show More