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 })
},
}))