Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 67 additions & 32 deletions apps/mobile/app/project/[projectId]/[sessionId]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View className="flex-row items-center gap-2 px-4 py-1.5 bg-accent/50 border-b border-accent">
<ActivityIndicator size="small" color={THEME[theme].mutedForeground} />
<Text className="text-xs text-muted-foreground">
{connectionStatus === "connecting"
? "Connecting..."
: connectionStatus === "reconnecting"
? "Reconnecting..."
: "Connectivity issues, retrying..."}
</Text>
</View>
)
}

export default function SessionScreen() {
const insets = useSafeAreaInsets()
const { colorScheme } = useColorScheme()
Expand Down Expand Up @@ -114,38 +137,54 @@ 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<string, boolean> }) => 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<string, string> }) => 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<string, SelectedModel> }) => 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<string, SelectedModel> }) => s.modelBySession[sessionId!],
[sessionId]
)
const storedModel = useChatStore(sessionModelSelector)

const modelByAgentProp = useMemo(
() => (currentAgentModel ? { [selectedAgent]: currentAgentModel } : {}),
[selectedAgent, currentAgentModel]
)

const selectedModel = currentAgentModel ?? storedModel ?? session?.model ?? null

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<string, QuestionRequest[]> }) => 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<string, PermissionRequest[]> }) => s.permissionsBySession[sessionId!] ?? EMPTY_PERMISSIONS,
[sessionId]
)
const pendingPermissions = usePermissions(pendingPermissionsSelector)
const removePermission = usePermissions((s) => s.removePermission)

const questionPollRef = useRef<ReturnType<typeof setTimeout> | null>(null)
Expand All @@ -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 {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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<string, Message>()
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)
Expand All @@ -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) {
Expand Down Expand Up @@ -897,18 +941,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi
paddingTop={insets.top}
/>

{(connectionStatus === "connecting" || connectionStatus === "reconnecting" || connectionStatus === "connectivity-issues") && (
<View className="flex-row items-center gap-2 px-4 py-1.5 bg-accent/50 border-b border-accent">
<ActivityIndicator size="small" color={THEME[theme].mutedForeground} />
<Text className="text-xs text-muted-foreground">
{connectionStatus === "connecting"
? "Connecting..."
: connectionStatus === "reconnecting"
? "Reconnecting..."
: "Connectivity issues, retrying..."}
</Text>
</View>
)}
<ConnectionBanner theme={theme} />

{messages.length > 0 ? (
<FlatList
Expand All @@ -928,10 +961,12 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi
ListHeaderComponent={StreamingIndicator}
removeClippedSubviews
maxToRenderPerBatch={10}
updateCellsBatchingPeriod={50}
windowSize={10}
initialNumToRender={15}
disableIntervalMomentum
inverted
onEndReached={loadMoreMessages}
onEndReached={isStreaming ? undefined : loadMoreMessages}
onEndReachedThreshold={0.5}
/>
) : (
Expand Down Expand Up @@ -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}
Expand Down
21 changes: 12 additions & 9 deletions apps/mobile/app/sessions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReturnType<typeof setTimeout> | null>(null)

Expand Down Expand Up @@ -212,14 +222,8 @@ export default function SessionsScreen() {
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
const [selectedSessionId, setSelectedSessionId] = React.useState<string | null>(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)
Expand Down Expand Up @@ -467,7 +471,6 @@ export default function SessionsScreen() {
isLast={index === section.data.length - 1}
onNavigate={handleNavigate}
onDelete={handleDelete}
now={now}
/>
)}
contentContainerStyle={{ paddingBottom: 32 }}
Expand Down
42 changes: 35 additions & 7 deletions apps/mobile/components/code-block.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<ReturnType<typeof setTimeout> | 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) {
Expand Down Expand Up @@ -136,6 +152,18 @@ export function CodeBlock({ text, language, theme, header = true, lineNumbers =
)}
</Highlight>
</ScrollView>
{truncated && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Show full code"
className="border-t border-border/40 px-3 py-2 active:opacity-60"
onPress={() => setExpanded(true)}
>
<Text className="text-xs text-muted-foreground">Show more ({Math.round(text.length / 1000)}k chars)</Text>
</Pressable>
)}
</View>
)
}

export const CodeBlock = memo(CodeBlockInner)
31 changes: 24 additions & 7 deletions apps/mobile/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion apps/mobile/store/chat.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,10 @@ export const useChatStore = create<ChatStore>()(
{
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,
}),
}
)
Expand Down
Loading
Loading