perf(ui): improve mobile session switching
Treat the mobile web surface as a constrained runtime so sync loads smaller message pages, keeps fewer warm session caches, and evicts heavy inactive sessions instead of retaining them across switches. Limit mobile message-record and turn-model caches to reduce memory pressure on phones while preserving bounded initial page expansion for large final turns. Split the mobile session status bar so the collapsed state avoids subscribing to the full session/status list; the expensive grouping work now only mounts for the expanded list. Verified with bun run type-check and bun run lint.
This commit is contained in:
@@ -1,7 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
import { useSessions, useAllSessionStatuses } from '@/sync/sync-context';
|
import {
|
||||||
|
useSessions,
|
||||||
|
useAllSessionStatuses,
|
||||||
|
useLiveSessionStatusCounts,
|
||||||
|
useSession,
|
||||||
|
useDirectorySync,
|
||||||
|
} from '@/sync/sync-context';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
@@ -65,6 +71,62 @@ const normalize = (value: string): string => {
|
|||||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDisplaySessionTitle = (session: Session): string => {
|
||||||
|
const title = session.title;
|
||||||
|
if (title && title.trim()) return title;
|
||||||
|
return 'New session';
|
||||||
|
};
|
||||||
|
|
||||||
|
const countUnreadSessions = (unseenCounts: Record<string, number>): number => {
|
||||||
|
let count = 0;
|
||||||
|
for (const value of Object.values(unseenCounts)) {
|
||||||
|
if (value > 0) count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
};
|
||||||
|
|
||||||
|
function useCurrentContextUsage(): SessionContextUsage | null {
|
||||||
|
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||||
|
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||||
|
|
||||||
|
const currentModel = getCurrentModel();
|
||||||
|
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
|
||||||
|
? (currentModel.limit as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
|
||||||
|
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
|
||||||
|
return getContextUsage(contextLimit, outputLimit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCurrentProjectDisplay() {
|
||||||
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
|
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||||
|
const activeProject = React.useMemo(
|
||||||
|
() => projects.find((project) => project.id === activeProjectId) ?? null,
|
||||||
|
[activeProjectId, projects],
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentProjectIconImageUrl = activeProject
|
||||||
|
? getProjectIconImageUrl(activeProject, {
|
||||||
|
themeVariant: currentTheme.metadata.variant,
|
||||||
|
iconColor: currentTheme.colors.surface.foreground,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
activeProjectId,
|
||||||
|
homeDirectory,
|
||||||
|
currentProjectLabel: activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory),
|
||||||
|
currentProjectIcon: activeProject?.icon,
|
||||||
|
currentProjectIconImageUrl,
|
||||||
|
currentProjectIconBackground: activeProject?.iconBackground ?? null,
|
||||||
|
currentProjectColor: activeProject?.color,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function useSessionGrouping(
|
function useSessionGrouping(
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
sessionStatus: Record<string, { type: string }> | undefined
|
sessionStatus: Record<string, { type: string }> | undefined
|
||||||
@@ -177,9 +239,7 @@ function useSessionHelpers(
|
|||||||
}, [agents]);
|
}, [agents]);
|
||||||
|
|
||||||
const getSessionTitle = React.useCallback((session: Session): string => {
|
const getSessionTitle = React.useCallback((session: Session): string => {
|
||||||
const title = session.title;
|
return getDisplaySessionTitle(session);
|
||||||
if (title && title.trim()) return title;
|
|
||||||
return 'New session';
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isRunning = React.useCallback((sessionId: string): boolean => {
|
const isRunning = React.useCallback((sessionId: string): boolean => {
|
||||||
@@ -1611,35 +1671,112 @@ function ExpandedView({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
const MobileSessionStatusBarCollapsed: React.FC<{ onExpand: () => void }> = ({
|
||||||
onSessionSwitch,
|
onExpand,
|
||||||
|
}) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sessionCount = useDirectorySync(React.useCallback((state) => state.session.length, []));
|
||||||
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const currentSession = useSession(currentSessionId, currentDirectory || undefined);
|
||||||
|
const statusCounts = useLiveSessionStatusCounts();
|
||||||
|
const unseenCounts = useNotificationStore((state) => state.index.session.unseenCount);
|
||||||
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
|
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||||
|
const contextUsage = useCurrentContextUsage();
|
||||||
|
const {
|
||||||
|
currentProjectLabel,
|
||||||
|
currentProjectIcon,
|
||||||
|
currentProjectIconImageUrl,
|
||||||
|
currentProjectIconBackground,
|
||||||
|
currentProjectColor,
|
||||||
|
} = useCurrentProjectDisplay();
|
||||||
|
|
||||||
|
const totalUnread = React.useMemo(() => countUnreadSessions(unseenCounts), [unseenCounts]);
|
||||||
|
const currentSessionTitle = currentSession
|
||||||
|
? getDisplaySessionTitle(currentSession)
|
||||||
|
: t('chat.mobileStatus.swipeHint');
|
||||||
|
|
||||||
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
|
const [editingTitle, setEditingTitle] = React.useState('');
|
||||||
|
|
||||||
|
if (sessionCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSessionDoubleClick = (sessionId: string, sessionTitle: string) => {
|
||||||
|
setEditingSessionId(sessionId);
|
||||||
|
setEditingTitle(sessionTitle);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCancel = () => {
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setEditingTitle('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = () => {
|
||||||
|
if (!editingSessionId) return;
|
||||||
|
const trimmed = editingTitle.trim();
|
||||||
|
const originalTitle = currentSession && currentSession.id === editingSessionId
|
||||||
|
? getDisplaySessionTitle(currentSession)
|
||||||
|
: '';
|
||||||
|
if (trimmed && trimmed !== originalTitle) {
|
||||||
|
void updateSessionTitle(editingSessionId, trimmed);
|
||||||
|
}
|
||||||
|
setEditingSessionId(null);
|
||||||
|
setEditingTitle('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CollapsedView
|
||||||
|
runningCount={statusCounts.running}
|
||||||
|
unreadCount={totalUnread}
|
||||||
|
currentSessionId={currentSessionId}
|
||||||
|
currentSessionTitle={currentSessionTitle}
|
||||||
|
currentProjectLabel={currentProjectLabel}
|
||||||
|
currentProjectIcon={currentProjectIcon}
|
||||||
|
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
||||||
|
currentProjectIconBackground={currentProjectIconBackground}
|
||||||
|
currentProjectColor={currentProjectColor}
|
||||||
|
onToggle={onExpand}
|
||||||
|
onNewSession={() => openNewSessionDraft()}
|
||||||
|
contextUsage={contextUsage}
|
||||||
|
editingSessionId={editingSessionId}
|
||||||
|
editingTitle={editingTitle}
|
||||||
|
onTitleDoubleClick={handleSessionDoubleClick}
|
||||||
|
onEditingTitleChange={setEditingTitle}
|
||||||
|
onEditSave={handleEditSave}
|
||||||
|
onEditCancel={handleEditCancel}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileSessionStatusBarExpanded: React.FC<MobileSessionStatusBarProps & { onCollapse: () => void }> = ({
|
||||||
|
onSessionSwitch,
|
||||||
|
onCollapse,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { currentTheme } = useThemeSystem();
|
|
||||||
const sessions = useSessions();
|
const sessions = useSessions();
|
||||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
const sessionStatus = useAllSessionStatuses();
|
const sessionStatus = useAllSessionStatuses();
|
||||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
|
||||||
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||||
const agents = useConfigStore((state) => state.agents);
|
const agents = useConfigStore((state) => state.agents);
|
||||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
|
||||||
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
|
||||||
const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed);
|
|
||||||
const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed);
|
|
||||||
|
|
||||||
// Project store
|
|
||||||
const projects = useProjectsStore((state) => state.projects);
|
|
||||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
|
||||||
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
const setActiveProject = useProjectsStore((state) => state.setActiveProject);
|
||||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||||
const removeProject = useProjectsStore((state) => state.removeProject);
|
const removeProject = useProjectsStore((state) => state.removeProject);
|
||||||
const getActiveProject = useProjectsStore((state) => state.getActiveProject);
|
const contextUsage = useCurrentContextUsage();
|
||||||
|
const {
|
||||||
// Directory store
|
projects,
|
||||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
activeProjectId,
|
||||||
|
homeDirectory,
|
||||||
|
currentProjectLabel,
|
||||||
|
currentProjectIcon,
|
||||||
|
currentProjectIconImageUrl,
|
||||||
|
currentProjectIconBackground,
|
||||||
|
currentProjectColor,
|
||||||
|
} = useCurrentProjectDisplay();
|
||||||
|
|
||||||
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus);
|
const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus);
|
||||||
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
|
const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus);
|
||||||
@@ -1654,32 +1791,11 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
|
const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId);
|
||||||
const currentSessionChildIndicators = currentSessionWithStatus?._childIndicators ?? [];
|
const currentSessionChildIndicators = currentSessionWithStatus?._childIndicators ?? [];
|
||||||
|
|
||||||
const activeProject = getActiveProject();
|
|
||||||
const currentProjectLabel = activeProject?.label || formatDirectoryName(activeProject?.path || '', homeDirectory);
|
|
||||||
const currentProjectIcon = activeProject?.icon;
|
|
||||||
const currentProjectIconImageUrl = activeProject
|
|
||||||
? getProjectIconImageUrl(activeProject, {
|
|
||||||
themeVariant: currentTheme.metadata.variant,
|
|
||||||
iconColor: currentTheme.colors.surface.foreground,
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const currentProjectIconBackground = activeProject?.iconBackground ?? null;
|
|
||||||
const currentProjectColor = activeProject?.color;
|
|
||||||
|
|
||||||
// Calculate token usage for current session
|
|
||||||
const currentModel = getCurrentModel();
|
|
||||||
const limit = currentModel && typeof currentModel.limit === 'object' && currentModel.limit !== null
|
|
||||||
? (currentModel.limit as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const contextLimit = (limit && typeof limit.context === 'number' ? limit.context : 0);
|
|
||||||
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
|
|
||||||
const contextUsage = getContextUsage(contextLimit, outputLimit);
|
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
const [editingSessionId, setEditingSessionId] = React.useState<string | null>(null);
|
||||||
const [editingTitle, setEditingTitle] = React.useState('');
|
const [editingTitle, setEditingTitle] = React.useState('');
|
||||||
|
|
||||||
if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) {
|
if (totalCount === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1750,32 +1866,6 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
sessionEvents.requestDirectoryDialog();
|
sessionEvents.requestDirectoryDialog();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isMobileSessionStatusBarCollapsed) {
|
|
||||||
return (
|
|
||||||
<CollapsedView
|
|
||||||
runningCount={totalRunning}
|
|
||||||
unreadCount={totalUnread}
|
|
||||||
currentSessionId={currentSessionId}
|
|
||||||
currentSessionTitle={currentSessionTitle}
|
|
||||||
currentProjectLabel={currentProjectLabel}
|
|
||||||
currentProjectIcon={currentProjectIcon}
|
|
||||||
currentProjectIconImageUrl={currentProjectIconImageUrl}
|
|
||||||
currentProjectIconBackground={currentProjectIconBackground}
|
|
||||||
currentProjectColor={currentProjectColor}
|
|
||||||
onToggle={() => setIsMobileSessionStatusBarCollapsed(false)}
|
|
||||||
onNewSession={handleCreateSession}
|
|
||||||
contextUsage={contextUsage}
|
|
||||||
childIndicators={currentSessionChildIndicators}
|
|
||||||
editingSessionId={editingSessionId}
|
|
||||||
editingTitle={editingTitle}
|
|
||||||
onTitleDoubleClick={handleSessionDoubleClick}
|
|
||||||
onEditingTitleChange={handleEditingTitleChange}
|
|
||||||
onEditSave={handleEditSave}
|
|
||||||
onEditCancel={handleEditCancel}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ExpandedView
|
<ExpandedView
|
||||||
sessions={sortedSessions}
|
sessions={sortedSessions}
|
||||||
@@ -1790,7 +1880,7 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
currentProjectColor={currentProjectColor}
|
currentProjectColor={currentProjectColor}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggleCollapse={() => {
|
onToggleCollapse={() => {
|
||||||
setIsMobileSessionStatusBarCollapsed(true);
|
onCollapse();
|
||||||
setIsExpanded(false);
|
setIsExpanded(false);
|
||||||
}}
|
}}
|
||||||
onNewSession={handleCreateSession}
|
onNewSession={handleCreateSession}
|
||||||
@@ -1816,3 +1906,31 @@ export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const MobileSessionStatusBar: React.FC<MobileSessionStatusBarProps> = ({
|
||||||
|
onSessionSwitch,
|
||||||
|
}) => {
|
||||||
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
|
const showMobileSessionStatusBar = useUIStore((state) => state.showMobileSessionStatusBar);
|
||||||
|
const isMobileSessionStatusBarCollapsed = useUIStore((state) => state.isMobileSessionStatusBarCollapsed);
|
||||||
|
const setIsMobileSessionStatusBarCollapsed = useUIStore((state) => state.setIsMobileSessionStatusBarCollapsed);
|
||||||
|
|
||||||
|
if (!isMobile || !showMobileSessionStatusBar) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMobileSessionStatusBarCollapsed) {
|
||||||
|
return (
|
||||||
|
<MobileSessionStatusBarCollapsed
|
||||||
|
onExpand={() => setIsMobileSessionStatusBarCollapsed(false)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileSessionStatusBarExpanded
|
||||||
|
onSessionSwitch={onSessionSwitch}
|
||||||
|
onCollapse={() => setIsMobileSessionStatusBarCollapsed(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
import type { TurnHistorySignals } from '../lib/turns/historySignals';
|
||||||
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||||
|
|
||||||
type ViewportAnchor = { messageId: string; offsetTop: number };
|
type ViewportAnchor = { messageId: string; offsetTop: number };
|
||||||
|
|
||||||
@@ -63,12 +64,19 @@ export interface UseChatTimelineControllerResult {
|
|||||||
const TURN_MODEL_CACHE_MAX = 30
|
const TURN_MODEL_CACHE_MAX = 30
|
||||||
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
const VSCODE_TURN_MODEL_CACHE_MAX = 4
|
||||||
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
const VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||||
|
const MOBILE_TURN_MODEL_CACHE_MAX = 4
|
||||||
|
const MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES = 30
|
||||||
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
const turnModelCache = new Map<string, { messages: ChatMessageEntry[]; model: TurnWindowModel }>()
|
||||||
const getTurnModelCacheMax = () => isVSCodeRuntime() ? VSCODE_TURN_MODEL_CACHE_MAX : TURN_MODEL_CACHE_MAX
|
const getTurnModelCacheMax = () => {
|
||||||
|
if (isVSCodeRuntime()) return VSCODE_TURN_MODEL_CACHE_MAX
|
||||||
|
if (isMobileSurfaceRuntime()) return MOBILE_TURN_MODEL_CACHE_MAX
|
||||||
|
return TURN_MODEL_CACHE_MAX
|
||||||
|
}
|
||||||
|
|
||||||
const shouldCacheTurnModelMessages = (messages: ChatMessageEntry[]): boolean => {
|
const shouldCacheTurnModelMessages = (messages: ChatMessageEntry[]): boolean => {
|
||||||
if (!isVSCodeRuntime()) return true
|
if (isVSCodeRuntime()) return messages.length <= VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES
|
||||||
return messages.length <= VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES
|
if (isMobileSurfaceRuntime()) return messages.length <= MOBILE_TURN_MODEL_CACHE_MAX_MESSAGES
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const rememberTurnModel = (key: string, value: { messages: ChatMessageEntry[]; model: TurnWindowModel }) => {
|
const rememberTurnModel = (key: string, value: { messages: ChatMessageEntry[]; model: TurnWindowModel }) => {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
|
|
||||||
|
export type HostedSurface = 'desktop' | 'mobile';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__OPENCHAMBER_SURFACE__?: HostedSurface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOBILE_SURFACE_MAX_WIDTH = 768;
|
||||||
|
|
||||||
|
const isTouchOrCoarsePointer = (): boolean => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
const coarsePointer = typeof window.matchMedia === 'function'
|
||||||
|
? window.matchMedia('(pointer: coarse)').matches || window.matchMedia('(hover: none)').matches
|
||||||
|
: false;
|
||||||
|
const touchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
|
||||||
|
return coarsePointer || touchPoints > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectHostedSurface = (): HostedSurface => {
|
||||||
|
if (typeof window === 'undefined') return 'desktop';
|
||||||
|
|
||||||
|
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
|
||||||
|
if (explicitSurface === 'mobile' || explicitSurface === 'desktop') {
|
||||||
|
return explicitSurface;
|
||||||
|
}
|
||||||
|
|
||||||
|
const override = new URLSearchParams(window.location.search).get('surface');
|
||||||
|
if (override === 'mobile' || override === 'desktop') {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDesktopShell()) return 'desktop';
|
||||||
|
|
||||||
|
const width = window.innerWidth || window.screen?.width || 0;
|
||||||
|
return width > 0 && width <= MOBILE_SURFACE_MAX_WIDTH && isTouchOrCoarsePointer()
|
||||||
|
? 'mobile'
|
||||||
|
: 'desktop';
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isMobileSurfaceRuntime = (): boolean => detectHostedSurface() === 'mobile';
|
||||||
@@ -7,6 +7,7 @@ import { useStore } from "zustand"
|
|||||||
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
import type { OpencodeClient } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createEventPipeline } from "./event-pipeline"
|
import { createEventPipeline } from "./event-pipeline"
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||||
|
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
||||||
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
|
import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer"
|
||||||
import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
|
import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store"
|
||||||
import { ChildStoreManager, type DirectoryStore } from "./child-store"
|
import { ChildStoreManager, type DirectoryStore } from "./child-store"
|
||||||
@@ -113,6 +114,35 @@ export function useAllSessionStatuses(): Record<string, SessionStatus> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LiveSessionStatusCounts = {
|
||||||
|
running: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_LIVE_SESSION_STATUS_COUNTS: LiveSessionStatusCounts = { running: 0 }
|
||||||
|
|
||||||
|
const isRunningSessionStatus = (status: SessionStatus | undefined): boolean => (
|
||||||
|
status?.type === "busy" || status?.type === "retry"
|
||||||
|
)
|
||||||
|
|
||||||
|
const areLiveSessionStatusCountsEquivalent = (left: LiveSessionStatusCounts, right: LiveSessionStatusCounts): boolean => (
|
||||||
|
left.running === right.running
|
||||||
|
)
|
||||||
|
|
||||||
|
export function useLiveSessionStatusCounts(): LiveSessionStatusCounts {
|
||||||
|
return useLiveSyncSelector(
|
||||||
|
useCallback((states) => {
|
||||||
|
let running = 0
|
||||||
|
for (const state of states) {
|
||||||
|
for (const status of Object.values(state.session_status ?? {})) {
|
||||||
|
if (isRunningSessionStatus(status)) running += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return running === 0 ? EMPTY_LIVE_SESSION_STATUS_COUNTS : { running }
|
||||||
|
}, []),
|
||||||
|
areLiveSessionStatusCountsEquivalent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function useAllLiveSessions(): Session[] {
|
export function useAllLiveSessions(): Session[] {
|
||||||
return useLiveSyncSelector(
|
return useLiveSyncSelector(
|
||||||
useCallback((states) => aggregateLiveSessions(states), []),
|
useCallback((states) => aggregateLiveSessions(states), []),
|
||||||
@@ -2012,6 +2042,8 @@ type SessionMessageRecordsSnapshot = {
|
|||||||
const SESSION_MESSAGE_RECORDS_CACHE_MAX = 40
|
const SESSION_MESSAGE_RECORDS_CACHE_MAX = 40
|
||||||
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
|
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
|
||||||
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
|
const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
|
||||||
|
const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4
|
||||||
|
const MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES = 30
|
||||||
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
|
const sessionMessageRecordsCache = new WeakMap<StoreApi<DirectoryStore>, Map<string, SessionMessageRecordsSnapshot>>()
|
||||||
|
|
||||||
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => (
|
const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => (
|
||||||
@@ -2049,13 +2081,22 @@ const rememberSessionMessageRecordsSnapshot = (
|
|||||||
if (!snapshot.sessionID) return
|
if (!snapshot.sessionID) return
|
||||||
const cache = getSessionMessageRecordsCache(store)
|
const cache = getSessionMessageRecordsCache(store)
|
||||||
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
|
const key = getSessionMessageRecordsCacheKey(snapshot.sessionID, snapshot.suspendPartUpdates)
|
||||||
if (isVSCodeRuntime() && snapshot.list.length > VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES) {
|
const constrainedMaxMessages = isVSCodeRuntime()
|
||||||
|
? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES
|
||||||
|
: isMobileSurfaceRuntime()
|
||||||
|
? MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX_MESSAGES
|
||||||
|
: null
|
||||||
|
if (constrainedMaxMessages !== null && snapshot.list.length > constrainedMaxMessages) {
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cache.delete(key)
|
cache.delete(key)
|
||||||
cache.set(key, snapshot)
|
cache.set(key, snapshot)
|
||||||
const max = isVSCodeRuntime() ? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX : SESSION_MESSAGE_RECORDS_CACHE_MAX
|
const max = isVSCodeRuntime()
|
||||||
|
? VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX
|
||||||
|
: isMobileSurfaceRuntime()
|
||||||
|
? MOBILE_SESSION_MESSAGE_RECORDS_CACHE_MAX
|
||||||
|
: SESSION_MESSAGE_RECORDS_CACHE_MAX
|
||||||
while (cache.size > max) {
|
while (cache.size > max) {
|
||||||
const oldest = cache.keys().next().value
|
const oldest = cache.keys().next().value
|
||||||
if (typeof oldest !== "string") break
|
if (typeof oldest !== "string") break
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK
|
|||||||
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
|
import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache"
|
||||||
import { stripMessageDiffSnapshots } from "./sanitize"
|
import { stripMessageDiffSnapshots } from "./sanitize"
|
||||||
import { isVSCodeRuntime } from "@/lib/desktop"
|
import { isVSCodeRuntime } from "@/lib/desktop"
|
||||||
|
import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface"
|
||||||
import {
|
import {
|
||||||
shouldSkipSessionPrefetch,
|
shouldSkipSessionPrefetch,
|
||||||
getSessionPrefetch,
|
getSessionPrefetch,
|
||||||
@@ -23,9 +24,11 @@ import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./
|
|||||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||||
const MESSAGE_PAGE_SIZE = 150
|
const MESSAGE_PAGE_SIZE = 150
|
||||||
const VSCODE_MESSAGE_PAGE_SIZE = 30
|
const VSCODE_MESSAGE_PAGE_SIZE = 30
|
||||||
|
const MOBILE_MESSAGE_PAGE_SIZE = 30
|
||||||
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const
|
||||||
const MAX_SEEN_DIRS = 30
|
const MAX_SEEN_DIRS = 30
|
||||||
const VSCODE_SESSION_CACHE_LIMIT = 4
|
const VSCODE_SESSION_CACHE_LIMIT = 4
|
||||||
|
const MOBILE_SESSION_CACHE_LIMIT = 4
|
||||||
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||||
|
|
||||||
// Shared across useSync() instances so cache eviction is based on app-level
|
// Shared across useSync() instances so cache eviction is based on app-level
|
||||||
@@ -39,9 +42,18 @@ type SyncMeta = {
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEffectiveSessionCacheLimit = () => isVSCodeRuntime() ? VSCODE_SESSION_CACHE_LIMIT : SESSION_CACHE_LIMIT
|
const isConstrainedSessionRuntime = () => isVSCodeRuntime() || isMobileSurfaceRuntime()
|
||||||
const getEffectiveMessagePageSize = () => isVSCodeRuntime() ? VSCODE_MESSAGE_PAGE_SIZE : MESSAGE_PAGE_SIZE
|
const getConstrainedInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1]
|
||||||
const getVSCodeInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1]
|
const getEffectiveSessionCacheLimit = () => {
|
||||||
|
if (isVSCodeRuntime()) return VSCODE_SESSION_CACHE_LIMIT
|
||||||
|
if (isMobileSurfaceRuntime()) return MOBILE_SESSION_CACHE_LIMIT
|
||||||
|
return SESSION_CACHE_LIMIT
|
||||||
|
}
|
||||||
|
const getEffectiveMessagePageSize = () => {
|
||||||
|
if (isVSCodeRuntime()) return VSCODE_MESSAGE_PAGE_SIZE
|
||||||
|
if (isMobileSurfaceRuntime()) return MOBILE_MESSAGE_PAGE_SIZE
|
||||||
|
return MESSAGE_PAGE_SIZE
|
||||||
|
}
|
||||||
const getDefaultMeta = (): SyncMeta => ({ limit: getEffectiveMessagePageSize(), cursor: undefined, complete: false, loading: false })
|
const getDefaultMeta = (): SyncMeta => ({ limit: getEffectiveMessagePageSize(), cursor: undefined, complete: false, loading: false })
|
||||||
|
|
||||||
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
|
function getPrefetchMeta(directory: string, sessionID: string): SyncMeta | undefined {
|
||||||
@@ -59,10 +71,10 @@ function sortParts(parts: Part[]) {
|
|||||||
return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
|
return parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHeavyVSCodeSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
|
function isHeavyConstrainedSessionCache(state: Pick<State, "message" | "part">, sessionID: string): boolean {
|
||||||
const messages = state.message[sessionID]
|
const messages = state.message[sessionID]
|
||||||
if (!messages || messages.length === 0) return false
|
if (!messages || messages.length === 0) return false
|
||||||
return messages.length > VSCODE_MESSAGE_PAGE_SIZE
|
return messages.length > getEffectiveMessagePageSize()
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUserMessage(message: Message): boolean {
|
function isUserMessage(message: Message): boolean {
|
||||||
@@ -187,7 +199,7 @@ export function useSync() {
|
|||||||
})
|
})
|
||||||
evict(directory, stale)
|
evict(directory, stale)
|
||||||
|
|
||||||
if (isVSCodeRuntime()) {
|
if (isConstrainedSessionRuntime()) {
|
||||||
const state = store.getState()
|
const state = store.getState()
|
||||||
const keep = new Set([sessionID, ...s, ...protectedIds])
|
const keep = new Set([sessionID, ...s, ...protectedIds])
|
||||||
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
|
const prefetched = Object.keys(state.message).filter((id) => !keep.has(id))
|
||||||
@@ -195,11 +207,11 @@ export function useSync() {
|
|||||||
|
|
||||||
// One very large inactive session can create memory/GC pressure that
|
// One very large inactive session can create memory/GC pressure that
|
||||||
// makes later small-session switches feel slow. Keep it while active,
|
// makes later small-session switches feel slow. Keep it while active,
|
||||||
// but do not retain it as a warm cache in the VSCode webview.
|
// but do not retain it as a warm cache in constrained shells.
|
||||||
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
|
const afterPrefetchEviction = prefetched.length > 0 ? store.getState() : state
|
||||||
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
|
const heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => {
|
||||||
if (id === sessionID || protectedIds.has(id)) return false
|
if (id === sessionID || protectedIds.has(id)) return false
|
||||||
return isHeavyVSCodeSessionCache(afterPrefetchEviction, id)
|
return isHeavyConstrainedSessionCache(afterPrefetchEviction, id)
|
||||||
})
|
})
|
||||||
if (heavyInactive.length > 0) {
|
if (heavyInactive.length > 0) {
|
||||||
for (const id of heavyInactive) s.delete(id)
|
for (const id of heavyInactive) s.delete(id)
|
||||||
@@ -279,12 +291,12 @@ export function useSync() {
|
|||||||
const limit = options?.before ? getEffectiveMessagePageSize() : m.limit
|
const limit = options?.before ? getEffectiveMessagePageSize() : m.limit
|
||||||
let page = await fetchMessages(sessionID, limit, options?.before)
|
let page = await fetchMessages(sessionID, limit, options?.before)
|
||||||
|
|
||||||
// VSCode keeps the initial page small for switch performance. Some
|
// Constrained shells keep the initial page small for switch performance. Some
|
||||||
// sessions have a very large final turn, so the latest 30 records can
|
// sessions have a very large final turn, so the latest 30 records can
|
||||||
// contain only assistant/tool records and no user boundary. That makes
|
// contain only assistant/tool records and no user boundary. That makes
|
||||||
// turn projection render an empty chat until the user manually loads
|
// turn projection render an empty chat until the user manually loads
|
||||||
// older messages. Expand only this initial tail fetch, with a hard cap.
|
// older messages. Expand only this initial tail fetch, with a hard cap.
|
||||||
if (!options?.before && isVSCodeRuntime() && !page.complete && !hasUserMessage(page.session)) {
|
if (!options?.before && isConstrainedSessionRuntime() && !page.complete && !hasUserMessage(page.session)) {
|
||||||
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
|
for (const nextLimit of VSCODE_INITIAL_PAGE_EXPANSION_LIMITS) {
|
||||||
if (nextLimit <= limit) continue
|
if (nextLimit <= limit) continue
|
||||||
page = await fetchMessages(sessionID, nextLimit)
|
page = await fetchMessages(sessionID, nextLimit)
|
||||||
@@ -347,26 +359,26 @@ export function useSync() {
|
|||||||
const cached = materialization.hasMessages && materialization.renderable && m.limit > 0
|
const cached = materialization.hasMessages && materialization.renderable && m.limit > 0
|
||||||
const prefetchInfo = !force ? getSessionPrefetch(directory, sessionID) : undefined
|
const prefetchInfo = !force ? getSessionPrefetch(directory, sessionID) : undefined
|
||||||
const knownCachedLimit = Math.max(m.limit, prefetchInfo?.limit ?? 0)
|
const knownCachedLimit = Math.max(m.limit, prefetchInfo?.limit ?? 0)
|
||||||
const needsVSCodeInitialTurnBoundary = isVSCodeRuntime()
|
const needsConstrainedInitialTurnBoundary = isConstrainedSessionRuntime()
|
||||||
&& cached
|
&& cached
|
||||||
&& !hasUserMessage(current.message[sessionID])
|
&& !hasUserMessage(current.message[sessionID])
|
||||||
&& knownCachedLimit < getVSCodeInitialPageExpansionMax()
|
&& knownCachedLimit < getConstrainedInitialPageExpansionMax()
|
||||||
&& !m.complete
|
&& !m.complete
|
||||||
&& prefetchInfo?.complete !== true
|
&& prefetchInfo?.complete !== true
|
||||||
&& Boolean(m.cursor ?? prefetchInfo?.cursor)
|
&& Boolean(m.cursor ?? prefetchInfo?.cursor)
|
||||||
if (needsVSCodeInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) {
|
if (needsConstrainedInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) {
|
||||||
setMetaFor(sessionID, {
|
setMetaFor(sessionID, {
|
||||||
limit: prefetchInfo.limit,
|
limit: prefetchInfo.limit,
|
||||||
cursor: prefetchInfo.cursor,
|
cursor: prefetchInfo.cursor,
|
||||||
complete: prefetchInfo.complete,
|
complete: prefetchInfo.complete,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const cachedReady = cached && !needsVSCodeInitialTurnBoundary
|
const cachedReady = cached && !needsConstrainedInitialTurnBoundary
|
||||||
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
|
const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found
|
||||||
if (cachedReady && hasSession && !force) return
|
if (cachedReady && hasSession && !force) return
|
||||||
|
|
||||||
// Skip if recently fetched (TTL)
|
// Skip if recently fetched (TTL)
|
||||||
if (!force && !needsVSCodeInitialTurnBoundary) {
|
if (!force && !needsConstrainedInitialTurnBoundary) {
|
||||||
if (shouldSkipSessionPrefetch({
|
if (shouldSkipSessionPrefetch({
|
||||||
hasMessages: cachedReady,
|
hasMessages: cachedReady,
|
||||||
info: prefetchInfo,
|
info: prefetchInfo,
|
||||||
|
|||||||
Reference in New Issue
Block a user