Merge upstream main into feat/subagent-cost-rollup
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 0–1px 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user