diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx index 27709da4..cf1f1973 100644 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.tsx +++ b/packages/ui/src/components/chat/MobileSessionStatusBar.tsx @@ -1,7 +1,13 @@ import React from 'react'; import { useSessionUIStore } from '@/sync/session-ui-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 { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -65,6 +71,62 @@ const normalize = (value: string): string => { 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): 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) + : 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( sessions: Session[], sessionStatus: Record | undefined @@ -177,9 +239,7 @@ function useSessionHelpers( }, [agents]); const getSessionTitle = React.useCallback((session: Session): string => { - const title = session.title; - if (title && title.trim()) return title; - return 'New session'; + return getDisplaySessionTitle(session); }, []); const isRunning = React.useCallback((sessionId: string): boolean => { @@ -1611,35 +1671,112 @@ function ExpandedView({ ); } -export const MobileSessionStatusBar: React.FC = ({ - onSessionSwitch, +const MobileSessionStatusBarCollapsed: React.FC<{ onExpand: () => void }> = ({ + 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(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 ( + openNewSessionDraft()} + contextUsage={contextUsage} + editingSessionId={editingSessionId} + editingTitle={editingTitle} + onTitleDoubleClick={handleSessionDoubleClick} + onEditingTitleChange={setEditingTitle} + onEditSave={handleEditSave} + onEditCancel={handleEditCancel} + /> + ); +}; + +const MobileSessionStatusBarExpanded: React.FC void }> = ({ + onSessionSwitch, + onCollapse, }) => { const { t } = useI18n(); - const { currentTheme } = useThemeSystem(); const sessions = useSessions(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const sessionStatus = useAllSessionStatuses(); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); - const getContextUsage = useSessionUIStore((state) => state.getContextUsage); const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); 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 setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); const removeProject = useProjectsStore((state) => state.removeProject); - const getActiveProject = useProjectsStore((state) => state.getActiveProject); - - // Directory store - const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const contextUsage = useCurrentContextUsage(); + const { + projects, + activeProjectId, + homeDirectory, + currentProjectLabel, + currentProjectIcon, + currentProjectIconImageUrl, + currentProjectIconBackground, + currentProjectColor, + } = useCurrentProjectDisplay(); const { sessions: sortedSessions, totalRunning, totalUnread, totalCount } = useSessionGrouping(sessions, sessionStatus); const { getSessionAgentName, getSessionTitle, needsAttention } = useSessionHelpers(agents, sessionStatus); @@ -1654,32 +1791,11 @@ export const MobileSessionStatusBar: React.FC = ({ const currentSessionWithStatus = sortedSessions.find((s) => s.id === currentSessionId); 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) - : 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 [editingSessionId, setEditingSessionId] = React.useState(null); const [editingTitle, setEditingTitle] = React.useState(''); - if (!isMobile || !showMobileSessionStatusBar || totalCount === 0) { + if (totalCount === 0) { return null; } @@ -1750,32 +1866,6 @@ export const MobileSessionStatusBar: React.FC = ({ sessionEvents.requestDirectoryDialog(); }; - if (isMobileSessionStatusBarCollapsed) { - return ( - setIsMobileSessionStatusBarCollapsed(false)} - onNewSession={handleCreateSession} - contextUsage={contextUsage} - childIndicators={currentSessionChildIndicators} - editingSessionId={editingSessionId} - editingTitle={editingTitle} - onTitleDoubleClick={handleSessionDoubleClick} - onEditingTitleChange={handleEditingTitleChange} - onEditSave={handleEditSave} - onEditCancel={handleEditCancel} - /> - ); - } - return ( = ({ currentProjectColor={currentProjectColor} isExpanded={isExpanded} onToggleCollapse={() => { - setIsMobileSessionStatusBarCollapsed(true); + onCollapse(); setIsExpanded(false); }} onNewSession={handleCreateSession} @@ -1816,3 +1906,31 @@ export const MobileSessionStatusBar: React.FC = ({ /> ); }; + +export const MobileSessionStatusBar: React.FC = ({ + 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 ( + setIsMobileSessionStatusBarCollapsed(false)} + /> + ); + } + + return ( + setIsMobileSessionStatusBarCollapsed(true)} + /> + ); +}; diff --git a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts index f677aade..328c53cd 100644 --- a/packages/ui/src/components/chat/hooks/useChatTimelineController.ts +++ b/packages/ui/src/components/chat/hooks/useChatTimelineController.ts @@ -14,6 +14,7 @@ import { import type { TurnHistorySignals } from '../lib/turns/historySignals'; import { getMemoryLimits, type SessionHistoryMeta } from '@/stores/types/sessionTypes'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface'; type ViewportAnchor = { messageId: string; offsetTop: number }; @@ -63,12 +64,19 @@ export interface UseChatTimelineControllerResult { const TURN_MODEL_CACHE_MAX = 30 const VSCODE_TURN_MODEL_CACHE_MAX = 4 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() -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 => { - if (!isVSCodeRuntime()) return true - return messages.length <= VSCODE_TURN_MODEL_CACHE_MAX_MESSAGES + if (isVSCodeRuntime()) 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 }) => { diff --git a/packages/ui/src/lib/runtimeSurface.ts b/packages/ui/src/lib/runtimeSurface.ts new file mode 100644 index 00000000..f820c537 --- /dev/null +++ b/packages/ui/src/lib/runtimeSurface.ts @@ -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'; diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index dc9ce48b..38cab519 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -7,6 +7,7 @@ import { useStore } from "zustand" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import { createEventPipeline } from "./event-pipeline" import { isVSCodeRuntime } from "@/lib/desktop" +import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" import { reduceGlobalEvent, applyGlobalProject, applyDirectoryEvent } from "./event-reducer" import { useGlobalSyncStore, type GlobalSyncStore } from "./global-sync-store" import { ChildStoreManager, type DirectoryStore } from "./child-store" @@ -113,6 +114,35 @@ export function useAllSessionStatuses(): Record { ) } +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[] { return useLiveSyncSelector( useCallback((states) => aggregateLiveSessions(states), []), @@ -2012,6 +2042,8 @@ type SessionMessageRecordsSnapshot = { const SESSION_MESSAGE_RECORDS_CACHE_MAX = 40 const VSCODE_SESSION_MESSAGE_RECORDS_CACHE_MAX = 4 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, Map>() const getSessionMessageRecordsCacheKey = (sessionID: string, suspendPartUpdates: boolean): string => ( @@ -2049,13 +2081,22 @@ const rememberSessionMessageRecordsSnapshot = ( if (!snapshot.sessionID) return const cache = getSessionMessageRecordsCache(store) 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) return } cache.delete(key) 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) { const oldest = cache.keys().next().value if (typeof oldest !== "string") break diff --git a/packages/ui/src/sync/use-sync.ts b/packages/ui/src/sync/use-sync.ts index cc1615a0..aecfb378 100644 --- a/packages/ui/src/sync/use-sync.ts +++ b/packages/ui/src/sync/use-sync.ts @@ -12,6 +12,7 @@ import { dropCachedSessionMessageRecordsSnapshots, useDirectoryStore, useSyncSDK import { dropSessionCaches, getProtectedSessionCacheIds } from "./session-cache" import { stripMessageDiffSnapshots } from "./sanitize" import { isVSCodeRuntime } from "@/lib/desktop" +import { isMobileSurfaceRuntime } from "@/lib/runtimeSurface" import { shouldSkipSessionPrefetch, getSessionPrefetch, @@ -23,9 +24,11 @@ import { getSessionMaterializationStatus, materializeSessionSnapshots } from "./ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) const MESSAGE_PAGE_SIZE = 150 const VSCODE_MESSAGE_PAGE_SIZE = 30 +const MOBILE_MESSAGE_PAGE_SIZE = 30 const VSCODE_INITIAL_PAGE_EXPANSION_LIMITS = [50, 80, 120] as const const MAX_SEEN_DIRS = 30 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) // Shared across useSync() instances so cache eviction is based on app-level @@ -39,9 +42,18 @@ type SyncMeta = { loading: boolean } -const getEffectiveSessionCacheLimit = () => isVSCodeRuntime() ? VSCODE_SESSION_CACHE_LIMIT : SESSION_CACHE_LIMIT -const getEffectiveMessagePageSize = () => isVSCodeRuntime() ? VSCODE_MESSAGE_PAGE_SIZE : MESSAGE_PAGE_SIZE -const getVSCodeInitialPageExpansionMax = () => VSCODE_INITIAL_PAGE_EXPANSION_LIMITS[VSCODE_INITIAL_PAGE_EXPANSION_LIMITS.length - 1] +const isConstrainedSessionRuntime = () => isVSCodeRuntime() || isMobileSurfaceRuntime() +const getConstrainedInitialPageExpansionMax = () => 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 }) 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)) } -function isHeavyVSCodeSessionCache(state: Pick, sessionID: string): boolean { +function isHeavyConstrainedSessionCache(state: Pick, sessionID: string): boolean { const messages = state.message[sessionID] if (!messages || messages.length === 0) return false - return messages.length > VSCODE_MESSAGE_PAGE_SIZE + return messages.length > getEffectiveMessagePageSize() } function isUserMessage(message: Message): boolean { @@ -187,7 +199,7 @@ export function useSync() { }) evict(directory, stale) - if (isVSCodeRuntime()) { + if (isConstrainedSessionRuntime()) { const state = store.getState() const keep = new Set([sessionID, ...s, ...protectedIds]) 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 // 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 heavyInactive = Object.keys(afterPrefetchEviction.message).filter((id) => { if (id === sessionID || protectedIds.has(id)) return false - return isHeavyVSCodeSessionCache(afterPrefetchEviction, id) + return isHeavyConstrainedSessionCache(afterPrefetchEviction, id) }) if (heavyInactive.length > 0) { for (const id of heavyInactive) s.delete(id) @@ -279,12 +291,12 @@ export function useSync() { const limit = options?.before ? getEffectiveMessagePageSize() : m.limit 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 // contain only assistant/tool records and no user boundary. That makes // turn projection render an empty chat until the user manually loads // 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) { if (nextLimit <= limit) continue page = await fetchMessages(sessionID, nextLimit) @@ -347,26 +359,26 @@ export function useSync() { const cached = materialization.hasMessages && materialization.renderable && m.limit > 0 const prefetchInfo = !force ? getSessionPrefetch(directory, sessionID) : undefined const knownCachedLimit = Math.max(m.limit, prefetchInfo?.limit ?? 0) - const needsVSCodeInitialTurnBoundary = isVSCodeRuntime() + const needsConstrainedInitialTurnBoundary = isConstrainedSessionRuntime() && cached && !hasUserMessage(current.message[sessionID]) - && knownCachedLimit < getVSCodeInitialPageExpansionMax() + && knownCachedLimit < getConstrainedInitialPageExpansionMax() && !m.complete && prefetchInfo?.complete !== true && Boolean(m.cursor ?? prefetchInfo?.cursor) - if (needsVSCodeInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) { + if (needsConstrainedInitialTurnBoundary && prefetchInfo && prefetchInfo.limit > m.limit) { setMetaFor(sessionID, { limit: prefetchInfo.limit, cursor: prefetchInfo.cursor, complete: prefetchInfo.complete, }) } - const cachedReady = cached && !needsVSCodeInitialTurnBoundary + const cachedReady = cached && !needsConstrainedInitialTurnBoundary const hasSession = Binary.search(current.session, sessionID, (s) => s.id).found if (cachedReady && hasSession && !force) return // Skip if recently fetched (TTL) - if (!force && !needsVSCodeInitialTurnBoundary) { + if (!force && !needsConstrainedInitialTurnBoundary) { if (shouldSkipSessionPrefetch({ hasMessages: cachedReady, info: prefetchInfo,