diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index 5254a70..586c9d9 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -46,6 +46,29 @@ const EMPTY_MESSAGES: Message[] = [] const MESSAGES_PER_PAGE = 20 +function ConnectionBanner({ theme }: { theme: "light" | "dark" }) { + const connectionStatus = useChatStore((s) => s.connectionStatus) + if ( + connectionStatus !== "connecting" && + connectionStatus !== "reconnecting" && + connectionStatus !== "connectivity-issues" + ) { + return null + } + return ( + + + + {connectionStatus === "connecting" + ? "Connecting..." + : connectionStatus === "reconnecting" + ? "Reconnecting..." + : "Connectivity issues, retrying..."} + + + ) +} + export default function SessionScreen() { const insets = useSafeAreaInsets() const { colorScheme } = useColorScheme() @@ -114,22 +137,34 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const project = useMemo(() => projects.find((p) => p.id === projectId) ?? null, [projects, projectId]) const session = useMemo(() => sessions.find((s) => s.id === sessionId) ?? null, [sessions, sessionId]) - const isStreaming = useChatStore( - useCallback((s) => s.streamingBySession[sessionId!] ?? false, [sessionId]) + const isStreamingSelector = useMemo( + () => (s: { streamingBySession: Record }) => s.streamingBySession[sessionId!] ?? false, + [sessionId] ) - const connectionStatus = useChatStore((s) => s.connectionStatus) - const draft = useChatStore( - useCallback((s) => s.draftBySession[sessionId!] ?? "", [sessionId]) + const isStreaming = useChatStore(isStreamingSelector) + const draftSelector = useMemo( + () => (s: { draftBySession: Record }) => s.draftBySession[sessionId!] ?? "", + [sessionId] ) + const draft = useChatStore(draftSelector) const setDraft = useChatStore((s) => s.setDraft) const clearDraft = useChatStore((s) => s.clearDraft) - const currentAgentModel = useChatStore( - useCallback((s) => s.modelByAgent[selectedAgent], [selectedAgent]) + const agentModelSelector = useMemo( + () => (s: { modelByAgent: Record }) => s.modelByAgent[selectedAgent], + [selectedAgent] ) + const currentAgentModel = useChatStore(agentModelSelector) const setModelByAgent = useChatStore((s) => s.setModelByAgent) const setModel = useChatStore((s) => s.setModel) - const storedModel = useChatStore( - useCallback((s) => s.modelBySession[sessionId!], [sessionId]) + const sessionModelSelector = useMemo( + () => (s: { modelBySession: Record }) => s.modelBySession[sessionId!], + [sessionId] + ) + const storedModel = useChatStore(sessionModelSelector) + + const modelByAgentProp = useMemo( + () => (currentAgentModel ? { [selectedAgent]: currentAgentModel } : {}), + [selectedAgent, currentAgentModel] ) const selectedModel = currentAgentModel ?? storedModel ?? session?.model ?? null @@ -137,15 +172,19 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const models = useModels((s) => s.models) const fetchAll = useModels((s) => s.fetchAll) - const pendingQuestions = useQuestions( - useCallback((s) => s.questionsBySession[sessionId!] ?? EMPTY_QUESTIONS, [sessionId]) + const pendingQuestionsSelector = useMemo( + () => (s: { questionsBySession: Record }) => s.questionsBySession[sessionId!] ?? EMPTY_QUESTIONS, + [sessionId] ) + const pendingQuestions = useQuestions(pendingQuestionsSelector) const setQuestions = useQuestions((s) => s.setQuestions) const removeQuestion = useQuestions((s) => s.removeQuestion) - const pendingPermissions = usePermissions( - useCallback((s) => s.permissionsBySession[sessionId!] ?? EMPTY_PERMISSIONS, [sessionId]) + const pendingPermissionsSelector = useMemo( + () => (s: { permissionsBySession: Record }) => s.permissionsBySession[sessionId!] ?? EMPTY_PERMISSIONS, + [sessionId] ) + const pendingPermissions = usePermissions(pendingPermissionsSelector) const removePermission = usePermissions((s) => s.removePermission) const questionPollRef = useRef | null>(null) @@ -154,7 +193,9 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const pollQuestions = useCallback(async () => { if (!connection?.url || !connection?.token) return if (appStateRef.current !== "active") { - questionPollRef.current = setTimeout(pollQuestions, 5000) + // SSE is the primary channel; poll only as a 30s fallback so we + // don't duplicate events and waste radio/battery. + questionPollRef.current = setTimeout(pollQuestions, 30000) return } try { @@ -181,7 +222,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi setQuestions(sessionId!, sessionQs) } } catch {} - questionPollRef.current = setTimeout(pollQuestions, 5000) + questionPollRef.current = setTimeout(pollQuestions, 30000) }, [connection?.url, connection?.token, projectId, sessionId, setQuestions]) useEffect(() => { @@ -578,11 +619,12 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi : raw const existing = getMessagesBySession(sessionId!) - const localMessages = existing.filter(m => m.id.startsWith("local-")) + const localMessages = existing.filter((m) => m.id.startsWith("local-") || m.id.startsWith("error-")) const map = new Map() for (const m of data) map.set(m.id, m) for (const m of localMessages) map.set(m.id, m) - setMessages(sessionId!, Array.from(map.values())) + const merged = Array.from(map.values()).sort((a, b) => a.time.created - b.time.created) + setMessages(sessionId!, merged) if (data.length < MESSAGES_PER_PAGE) { setHasMoreMessages(false) @@ -598,7 +640,9 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi setIsLoadingMore(true) const existing = getMessagesBySession(sessionId!) - const offset = existing.length + // Optimistic local-/error- messages aren't on the server; counting + // them in offset would skip real messages and create holes. + const offset = existing.filter((m) => !m.id.startsWith("local-") && !m.id.startsWith("error-")).length const raw = await getMessages(connection.url, connection.token, sessionId!, MESSAGES_PER_PAGE, offset) if (raw) { @@ -897,18 +941,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi paddingTop={insets.top} /> - {(connectionStatus === "connecting" || connectionStatus === "reconnecting" || connectionStatus === "connectivity-issues") && ( - - - - {connectionStatus === "connecting" - ? "Connecting..." - : connectionStatus === "reconnecting" - ? "Reconnecting..." - : "Connectivity issues, retrying..."} - - - )} + {messages.length > 0 ? ( ) : ( @@ -1014,7 +1049,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi connectionUrl={connection?.url} connectionToken={connection?.token} theme={theme} - modelByAgent={currentAgentModel ? { [selectedAgent]: currentAgentModel } : {}} + modelByAgent={modelByAgentProp} onModelSelect={handleModelSelect} onVariantSelect={handleVariantSelect} onSessionModelUpdate={handleSessionModelUpdate} diff --git a/apps/mobile/app/sessions.tsx b/apps/mobile/app/sessions.tsx index ac16ccd..3668dec 100644 --- a/apps/mobile/app/sessions.tsx +++ b/apps/mobile/app/sessions.tsx @@ -69,21 +69,31 @@ function formatTime(ts: number, now: number) { return new Date(ts).toLocaleDateString() } +// Bucket "now" to 5-minute windows so rows sharing a bucket don't re-render +// every minute. Each row subscribes to the bucket, not a raw timestamp. +function useNowBucket(): number { + const [bucket, setBucket] = React.useState(() => Math.floor(Date.now() / 300000)) + React.useEffect(() => { + const interval = setInterval(() => setBucket(Math.floor(Date.now() / 300000)), 60000) + return () => clearInterval(interval) + }, []) + return bucket * 300000 +} + const SessionItem = React.memo(function SessionItem({ session, isLast, onNavigate, onDelete, - now, }: { session: Session isLast: boolean onNavigate: (id: string) => void onDelete: (id: string) => void - now: number }) { const theme = useColorScheme().colorScheme ?? "light" const isStreaming = useChatStore((s) => s.streamingBySession[session.id] ?? false) + const now = useNowBucket() const [menuVisible, setMenuVisible] = React.useState(false) const pressTimer = React.useRef | null>(null) @@ -212,14 +222,8 @@ export default function SessionsScreen() { const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) const [selectedSessionId, setSelectedSessionId] = React.useState(null) const [creatingSession, setCreatingSession] = React.useState(false) - const [now, setNow] = React.useState(Date.now()) const removeSession = useSessions((s) => s.removeSession) - React.useEffect(() => { - const interval = setInterval(() => setNow(Date.now()), 60000) - return () => clearInterval(interval) - }, []) - const handleCreateSession = React.useCallback(async () => { if (!connection?.url || !connection?.token || !project?.directory || creatingSession) return setCreatingSession(true) @@ -467,7 +471,6 @@ export default function SessionsScreen() { isLast={index === section.data.length - 1} onNavigate={handleNavigate} onDelete={handleDelete} - now={now} /> )} contentContainerStyle={{ paddingBottom: 32 }} diff --git a/apps/mobile/components/code-block.tsx b/apps/mobile/components/code-block.tsx index f2475cb..7b1a12d 100644 --- a/apps/mobile/components/code-block.tsx +++ b/apps/mobile/components/code-block.tsx @@ -1,4 +1,4 @@ -import { useState } from "react" +import { memo, useEffect, useMemo, useRef, useState } from "react" import { Platform, ScrollView, Pressable, View, type TextStyle } from "react-native" import * as Clipboard from "expo-clipboard" import CheckIcon from "lucide-react-native/dist/esm/icons/check" @@ -24,18 +24,34 @@ interface CodeBlockProps { lineNumbers?: boolean } -export function CodeBlock({ text, language, theme, header = true, lineNumbers = false }: CodeBlockProps) { +const MAX_CODE_CHARS = 20000 + +function CodeBlockInner({ text, language, theme, header = true, lineNumbers = false }: CodeBlockProps) { const [copied, setCopied] = useState(false) - ensurePrismLanguages() + const [expanded, setExpanded] = useState(false) + const copyTimer = useRef | null>(null) + useEffect(() => { + // Defer sync prism grammar requires off the render path. + const t = setTimeout(() => ensurePrismLanguages(), 0) + return () => { + clearTimeout(t) + if (copyTimer.current) clearTimeout(copyTimer.current) + } + }, []) const prismTheme = theme === "dark" ? themes.oneDark : themes.github - const normalizedLanguage = normalizeLanguage(language) - const label = displayLanguage(language) - const code = text.endsWith("\n") ? text.slice(0, -1) : text + const normalizedLanguage = useMemo(() => normalizeLanguage(language), [language]) + const label = useMemo(() => displayLanguage(language), [language]) + const truncated = text.length > MAX_CODE_CHARS && !expanded + const code = useMemo(() => { + const raw = truncated ? text.slice(0, MAX_CODE_CHARS) : text + return raw.endsWith("\n") ? raw.slice(0, -1) : raw + }, [text, truncated]) const copyCode = async () => { await Clipboard.setStringAsync(code) setCopied(true) - setTimeout(() => setCopied(false), 1500) + if (copyTimer.current) clearTimeout(copyTimer.current) + copyTimer.current = setTimeout(() => setCopied(false), 1500) } if (!header) { @@ -136,6 +152,18 @@ export function CodeBlock({ text, language, theme, header = true, lineNumbers = )} + {truncated && ( + setExpanded(true)} + > + Show more ({Math.round(text.length / 1000)}k chars) + + )} ) } + +export const CodeBlock = memo(CodeBlockInner) diff --git a/apps/mobile/components/message-item.tsx b/apps/mobile/components/message-item.tsx index 584600e..e21263a 100644 --- a/apps/mobile/components/message-item.tsx +++ b/apps/mobile/components/message-item.tsx @@ -381,7 +381,23 @@ function PartRenderer({ part, index, message, theme, projectId, sessionId, pendi } } -const MemoPartRenderer = memo(PartRenderer, (prev, next) => prev.part === next.part && prev.index === next.index && prev.message === next.message && prev.theme === next.theme && prev.projectId === next.projectId && prev.sessionId === next.sessionId && prev.pendingQuestions === next.pendingQuestions && prev.pendingPermissions === next.pendingPermissions && prev.streaming === next.streaming) +const MemoPartRenderer = memo( + PartRenderer, + (prev, next) => + prev.part === next.part && + prev.index === next.index && + // Message objects are recreated per SSE delta ({...info, parts:[...]}), + // so compare stable identity instead of object ref to avoid + // re-rendering every part of the streaming message per token. + prev.message.id === next.message.id && + (prev.message.parts?.length ?? 0) === (next.message.parts?.length ?? 0) && + prev.theme === next.theme && + prev.projectId === next.projectId && + prev.sessionId === next.sessionId && + prev.pendingQuestions === next.pendingQuestions && + prev.pendingPermissions === next.pendingPermissions && + prev.streaming === next.streaming +) function getPlainText(message: Message): string { if (!message.parts) return "" @@ -398,16 +414,17 @@ function formatTokens(n: number): string { } function MessageMetadata({ message, theme }: { message: Message; theme: "light" | "dark" }) { - const models = useModels((s) => s.models) - const providers = useModels((s) => s.providers) + // Look up via getState instead of subscribing: subscribing every row to + // the whole models/providers arrays re-renders N rows on each fetch. + const snapshot = useModels.getState() + const model = snapshot.models.find((m) => m.id === (message as { modelID?: string }).modelID && m.providerID === (message as { providerID?: string }).providerID) + const provider = snapshot.providers.find((p) => p.id === (message as { providerID?: string }).providerID) if (message.role !== "assistant") return null if (!message.time.completed) return null - const model = models.find((m) => m.id === message.modelID && m.providerID === message.providerID) - const provider = providers.find((p) => p.id === message.providerID) - const modelName = model?.name ?? message.modelID - const providerName = provider?.name ?? message.providerID + const modelName = model?.name ?? (message as { modelID?: string }).modelID + const providerName = provider?.name ?? (message as { providerID?: string }).providerID const totalTokens = message.tokens.input + message.tokens.output + message.tokens.reasoning + message.tokens.cache.read + message.tokens.cache.write const costStr = message.cost.toFixed(4) diff --git a/apps/mobile/store/chat.store.ts b/apps/mobile/store/chat.store.ts index 4b834ea..89ce248 100644 --- a/apps/mobile/store/chat.store.ts +++ b/apps/mobile/store/chat.store.ts @@ -120,9 +120,10 @@ export const useChatStore = create()( { name: "crosscode-chat", storage: createJSONStorage(() => AsyncStorage), + // Drafts change per keystroke — persisting them rewrites AsyncStorage + // on every char and causes I/O jank. Keep drafts memory-only. partialize: (state) => ({ modelByAgent: state.modelByAgent, - draftBySession: state.draftBySession, }), } ) diff --git a/apps/mobile/store/messages.store.ts b/apps/mobile/store/messages.store.ts index 900d574..d48e26e 100644 --- a/apps/mobile/store/messages.store.ts +++ b/apps/mobile/store/messages.store.ts @@ -135,6 +135,13 @@ type MessagesStore = { getMessagesBySession: (sessionId: string) => Message[] } +const MAX_MESSAGES_PER_SESSION = 500 + +function capMessages(messages: Message[]): Message[] { + if (messages.length <= MAX_MESSAGES_PER_SESSION) return messages + return messages.slice(messages.length - MAX_MESSAGES_PER_SESSION) +} + export const useMessages = create()( (set, get) => ({ messagesBySession: {}, @@ -151,7 +158,7 @@ export const useMessages = create()( return { messagesBySession: { ...state.messagesBySession, - [sessionId]: Array.from(map.values()), + [sessionId]: capMessages(Array.from(map.values())), }, } }), @@ -162,7 +169,7 @@ export const useMessages = create()( set((state) => ({ messagesBySession: { ...state.messagesBySession, - [sessionId]: messages, + [sessionId]: capMessages(messages), }, })), }) diff --git a/apps/mobile/store/models.store.ts b/apps/mobile/store/models.store.ts index 10fcfa8..95fe4f4 100644 --- a/apps/mobile/store/models.store.ts +++ b/apps/mobile/store/models.store.ts @@ -7,21 +7,37 @@ type ModelsStore = { fetchAll: (url: string, token: string) => Promise } +let inflight: Promise | null = null +let lastFetchedAt = 0 +const STALE_MS = 5 * 60 * 1000 + export const useModels = create()((set, get) => ({ models: [], providers: [], fetchAll: async (url, token) => { - const [models, providers] = await Promise.all([ - fetchModels(url, token), - fetchProviders(url, token), - ]) - const current = get() - if (current.models.length === models.length && current.providers.length === providers.length) { - const modelsMatch = models.every((m, i) => m.id === current.models[i]?.id) - const providersMatch = providers.every((p, i) => p.id === current.providers[i]?.id) - if (modelsMatch && providersMatch) return + // Dedup concurrent calls (chat + models screens mount together) + // and skip refetch within stale window. + if (inflight) return inflight + if (Date.now() - lastFetchedAt < STALE_MS && get().models.length > 0) return + inflight = (async () => { + const [models, providers] = await Promise.all([ + fetchModels(url, token), + fetchProviders(url, token), + ]) + const current = get() + lastFetchedAt = Date.now() + if (current.models.length === models.length && current.providers.length === providers.length) { + const modelsMatch = models.every((m, i) => m.id === current.models[i]?.id && m.providerID === current.models[i]?.providerID && m.name === current.models[i]?.name) + const providersMatch = providers.every((p, i) => p.id === current.providers[i]?.id && p.name === current.providers[i]?.name) + if (modelsMatch && providersMatch) return + } + set({ models, providers }) + })() + try { + await inflight + } finally { + inflight = null } - set({ models, providers }) }, }))