diff --git a/apps/mobile/app/(tabs)/user.tsx b/apps/mobile/app/(tabs)/user.tsx
index 2bc3a0c..eb290d5 100644
--- a/apps/mobile/app/(tabs)/user.tsx
+++ b/apps/mobile/app/(tabs)/user.tsx
@@ -3,7 +3,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"
import { Text } from "@/components/ui/text"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { ExternalLink, Moon, Sun, LogOut, User as UserIcon, Bell, Mail, ArrowLeft, Zap, ChevronRight, RotateCcw } from "lucide-react-native"
+import { ExternalLink, Moon, Sun, LogOut, User as UserIcon, Bell, Mail, ArrowLeft, Zap, ChevronRight, RotateCcw, Sparkles } from "lucide-react-native"
import { useRouter } from "expo-router"
import { THEME } from "@/lib/theme"
import { useColorScheme } from "nativewind"
@@ -19,6 +19,7 @@ import { OpencodeStatsCard } from "@/components/OpencodeStatsCard"
import React from "react"
const SUPPORT_EMAIL = "crosscode@sish.work"
+const WEB_APP_URL = "https://crosscode.site"
export default function UserPage() {
const insets = useSafeAreaInsets()
@@ -88,6 +89,8 @@ export default function UserPage() {
const openLink = (url: string) => Linking.openURL(url)
+ const openPricing = () => openLink(`${serverUrl ?? WEB_APP_URL}/pricing`)
+
const handleNotificationsChange = React.useCallback(async (value: boolean) => {
setNotifications(value)
if (serverUrl && sessionToken) {
@@ -151,10 +154,16 @@ export default function UserPage() {
-
- {user.email}
+
+ {user.email}
{user.tier} tier
+ {user.tier === "free" && (
+
+ )}
diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx
index 225ad5b..b755479 100644
--- a/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx
+++ b/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx
@@ -9,8 +9,25 @@ import { Text } from "@/components/ui/text"
import { THEME } from "@/lib/theme"
import { useDiffStore } from "@/store/diff.store"
import { fetchSessionDiffs, FileDiff } from "@/lib/diff"
+import { getMessages } from "@/lib/messages"
+import { Message, Part } from "@/store/messages.store"
import { useConnections } from "@/store/connection.store"
+function toFileDiff(value: unknown): FileDiff | null {
+ if (!value || typeof value !== "object") return null
+ const diff = value as Record
+ const file = typeof diff.file === "string" ? diff.file : undefined
+ if (!file) return null
+ return {
+ file,
+ patch: typeof diff.patch === "string" ? diff.patch : "",
+ before: typeof diff.before === "string" ? diff.before : "",
+ after: typeof diff.after === "string" ? diff.after : "",
+ additions: typeof diff.additions === "number" ? diff.additions : 0,
+ deletions: typeof diff.deletions === "number" ? diff.deletions : 0,
+ }
+}
+
export default function FilesPage() {
const insets = useSafeAreaInsets()
const router = useRouter()
@@ -28,8 +45,36 @@ export default function FilesPage() {
const loadDiffs = useCallback(async () => {
if (!connection?.url || !connection?.token || !sessionId) return
- const data = await fetchSessionDiffs(connection.url, connection.token, sessionId)
- setFiles(data)
+
+ const byFile = new Map()
+ let lastUserMessageId: string | undefined
+
+ try {
+ const raw = await getMessages(connection.url, connection.token, sessionId, 100)
+ if (raw && raw.length > 0) {
+ const data =
+ "info" in raw[0]
+ ? (raw as unknown as Array<{ info: Message; parts: Part[] }>).map((m) => ({ ...m.info, parts: m.parts }))
+ : raw
+
+ for (const message of data) {
+ if (message.role !== "user") continue
+ lastUserMessageId = message.id
+ const diffs = (message as { summary?: { diffs?: unknown[] } }).summary?.diffs
+ for (const diff of diffs ?? []) {
+ const normalized = toFileDiff(diff)
+ if (normalized) byFile.set(normalized.file, normalized)
+ }
+ }
+ }
+ } catch {}
+
+ if (byFile.size === 0 && lastUserMessageId) {
+ const fallback = await fetchSessionDiffs(connection.url, connection.token, sessionId, lastUserMessageId)
+ for (const diff of fallback) byFile.set(diff.file, diff)
+ }
+
+ setFiles(Array.from(byFile.values()))
setLoading(false)
}, [connection?.url, connection?.token, sessionId])
diff --git a/apps/mobile/components/code-block.tsx b/apps/mobile/components/code-block.tsx
new file mode 100644
index 0000000..55f3a4a
--- /dev/null
+++ b/apps/mobile/components/code-block.tsx
@@ -0,0 +1,87 @@
+import { useState } from "react"
+import { Platform, ScrollView, Pressable, View, type TextStyle } from "react-native"
+import * as Clipboard from "expo-clipboard"
+import { CheckIcon, CopyIcon } from "lucide-react-native"
+import { Highlight, themes } from "prism-react-renderer"
+import { displayLanguage, normalizeLanguage } from "@/lib/prism"
+import { THEME } from "@/lib/theme"
+import { Text } from "@/components/ui/text"
+
+const monoFont = Platform.select({ ios: "Menlo", default: "monospace" })
+
+const codeTextStyle: TextStyle = {
+ fontFamily: monoFont,
+ fontSize: 13,
+ lineHeight: 20,
+}
+
+interface CodeBlockProps {
+ text: string
+ language?: string
+ theme: "light" | "dark"
+}
+
+export function CodeBlock({ text, language, theme }: CodeBlockProps) {
+ const [copied, setCopied] = useState(false)
+ 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 copyCode = async () => {
+ await Clipboard.setStringAsync(code)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }
+
+ return (
+
+
+ {label}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {({ tokens, getTokenProps }) => (
+
+ {tokens.map((line, lineIndex) => (
+
+ {line.map((token, tokenIndex) => {
+ const tokenProps = getTokenProps({ token })
+ return (
+
+ {tokenProps.children}
+
+ )
+ })}
+
+ ))}
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/mobile/components/memo-markdown.tsx b/apps/mobile/components/memo-markdown.tsx
index 12a5812..718f6a9 100644
--- a/apps/mobile/components/memo-markdown.tsx
+++ b/apps/mobile/components/memo-markdown.tsx
@@ -1,8 +1,11 @@
-import { memo, useEffect, useRef, useState } from "react"
+import { memo, useEffect, useMemo, useRef, useState } from "react"
import { type ReactNode, Fragment } from "react"
import Markdown from "react-native-marked"
import type { MarkedStyles } from "react-native-marked"
-import { Text, View, type TextStyle, type ViewStyle, type ImageStyle } from "react-native"
+import { Platform, Text, View, type TextStyle, type ViewStyle, type ImageStyle } from "react-native"
+import { CodeBlock } from "@/components/code-block"
+
+const monoFont = Platform.select({ ios: "Menlo", default: "monospace" })
const manropeStyles: MarkedStyles = {
text: { fontFamily: "Manrope_400Regular" },
@@ -10,13 +13,13 @@ const manropeStyles: MarkedStyles = {
strong: { fontFamily: "Manrope_700Bold" },
strikethrough: { fontFamily: "Manrope_400Regular" },
link: { fontFamily: "Manrope_400Regular" },
- h1: { fontFamily: "Manrope_700Bold" },
- h2: { fontFamily: "Manrope_700Bold" },
- h3: { fontFamily: "Manrope_600SemiBold" },
- h4: { fontFamily: "Manrope_600SemiBold" },
- h5: { fontFamily: "Manrope_600SemiBold" },
- h6: { fontFamily: "Manrope_600SemiBold" },
- codespan: { fontFamily: "Manrope_400Regular" },
+ h1: { fontFamily: "Manrope_700Bold", fontSize: 20, lineHeight: 28, fontWeight: "700" },
+ h2: { fontFamily: "Manrope_700Bold", fontSize: 18, lineHeight: 25, fontWeight: "700" },
+ h3: { fontFamily: "Manrope_600SemiBold", fontSize: 17, lineHeight: 24, fontWeight: "600" },
+ h4: { fontFamily: "Manrope_600SemiBold", fontSize: 16, lineHeight: 23, fontWeight: "600" },
+ h5: { fontFamily: "Manrope_600SemiBold", fontSize: 15, lineHeight: 21, fontWeight: "600" },
+ h6: { fontFamily: "Manrope_600SemiBold", fontSize: 14, lineHeight: 20, fontWeight: "600" },
+ codespan: { fontFamily: monoFont },
li: { fontFamily: "Manrope_400Regular" },
}
@@ -25,7 +28,7 @@ function keyedChildren(children: ReactNode[]): ReactNode {
return children.map((child, i) => {child})
}
-const customRenderer = {
+const createCustomRenderer = (theme: "light" | "dark") => ({
paragraph(children: ReactNode[], styles?: ViewStyle) {
return {keyedChildren(children)}
},
@@ -36,7 +39,7 @@ const customRenderer = {
return {Array.isArray(text) ? keyedChildren(text) : text}
},
code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle) {
- return {text}
+ return
},
hr(styles?: ViewStyle) {
return
@@ -83,11 +86,12 @@ const customRenderer = {
table(header: ReactNode[][], rows: ReactNode[][][], tableStyle?: ViewStyle, rowStyle?: ViewStyle, cellStyle?: ViewStyle) {
return Table
},
-}
+})
-function MarkdownRendererInner({ children, streaming }: { children: string; streaming?: boolean }) {
+function MarkdownRendererInner({ children, streaming, theme }: { children: string; streaming?: boolean; theme: "light" | "dark" }) {
const [parsed, setParsed] = useState(children)
const timerRef = useRef | null>(null)
+ const customRenderer = useMemo(() => createCustomRenderer(theme), [theme])
useEffect(() => {
if (streaming) {
@@ -121,6 +125,6 @@ function MarkdownRendererInner({ children, streaming }: { children: string; stre
)
}
-const MemoMarkdown = memo(MarkdownRendererInner, (prev, next) => prev.children === next.children && prev.streaming === next.streaming)
+const MemoMarkdown = memo(MarkdownRendererInner, (prev, next) => prev.children === next.children && prev.streaming === next.streaming && prev.theme === next.theme)
export default MemoMarkdown
diff --git a/apps/mobile/components/message-item.tsx b/apps/mobile/components/message-item.tsx
index 904083f..5c3abba 100644
--- a/apps/mobile/components/message-item.tsx
+++ b/apps/mobile/components/message-item.tsx
@@ -84,7 +84,7 @@ function getErrorHint(name?: string): string | undefined {
function PartRenderer({ part, index, message, theme, projectId, sessionId, pendingQuestions, onQuestionReply, onQuestionReject, pendingPermissions, onPermissionReply, streaming }: { part: Part; index: number; message: Message; theme: "light" | "dark"; projectId: string; sessionId: string; pendingQuestions?: QuestionRequest[]; onQuestionReply?: (requestId: string, answers: string[][]) => void; onQuestionReject?: (requestId: string) => void; pendingPermissions?: PermissionRequest[]; onPermissionReply?: (requestId: string, reply: "once" | "always" | "reject", message?: string) => void; streaming?: boolean }) {
switch (part.type) {
case "text":
- return {part.text}
+ return {part.text}
case "reasoning":
return null
case "tool-invocation":
diff --git a/apps/mobile/lib/diff.ts b/apps/mobile/lib/diff.ts
index b62942c..a9d2abe 100644
--- a/apps/mobile/lib/diff.ts
+++ b/apps/mobile/lib/diff.ts
@@ -29,9 +29,10 @@ function normalizeFileDiff(value: unknown): FileDiff | null {
}
}
-export async function fetchSessionDiffs(url: string, token: string, sessionId: string): Promise {
+export async function fetchSessionDiffs(url: string, token: string, sessionId: string, messageID?: string): Promise {
try {
- const res = await fetch(`${url}/session/${sessionId}/diff`, {
+ const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : ""
+ const res = await fetch(`${url}/session/${sessionId}/diff${query}`, {
method: "GET",
headers: {
"Authorization": getAuthHeader(token),
diff --git a/apps/mobile/lib/prism.ts b/apps/mobile/lib/prism.ts
new file mode 100644
index 0000000..b78c614
--- /dev/null
+++ b/apps/mobile/lib/prism.ts
@@ -0,0 +1,73 @@
+import { Prism } from "prism-react-renderer"
+
+type PrismGlobal = typeof globalThis & { Prism?: typeof Prism }
+
+const prismGlobal = globalThis as PrismGlobal
+
+prismGlobal.Prism = Prism
+
+// These grammars are not included in prism-react-renderer's default bundle.
+require("prismjs/components/prism-bash")
+require("prismjs/components/prism-c")
+require("prismjs/components/prism-csharp")
+require("prismjs/components/prism-dart")
+require("prismjs/components/prism-diff")
+require("prismjs/components/prism-java")
+require("prismjs/components/prism-php")
+require("prismjs/components/prism-ruby")
+require("prismjs/components/prism-sql")
+
+const languageAliases: Record = {
+ "c#": "csharp",
+ "c++": "cpp",
+ html: "markup",
+ javascript: "javascript",
+ js: "javascript",
+ md: "markdown",
+ py: "python",
+ sh: "bash",
+ shell: "bash",
+ ts: "typescript",
+ typescript: "typescript",
+ xml: "markup",
+ yml: "yaml",
+}
+
+const languageLabels: Record = {
+ bash: "Bash",
+ c: "C",
+ cpp: "C++",
+ csharp: "C#",
+ css: "CSS",
+ dart: "Dart",
+ diff: "Diff",
+ go: "Go",
+ graphql: "GraphQL",
+ java: "Java",
+ javascript: "JavaScript",
+ json: "JSON",
+ kotlin: "Kotlin",
+ markup: "HTML",
+ markdown: "Markdown",
+ php: "PHP",
+ python: "Python",
+ ruby: "Ruby",
+ rust: "Rust",
+ sql: "SQL",
+ swift: "Swift",
+ typescript: "TypeScript",
+ yaml: "YAML",
+}
+
+export function normalizeLanguage(language?: string): string {
+ const normalized = language?.trim().toLowerCase().replace(/^language-/, "")
+ if (!normalized) return "text"
+ return languageAliases[normalized] ?? normalized
+}
+
+export function displayLanguage(language?: string): string {
+ const normalized = normalizeLanguage(language)
+ return languageLabels[normalized] ?? (normalized === "text" ? "Code" : normalized.toUpperCase())
+}
+
+export { Prism }
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 2c71192..682538f 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -39,6 +39,8 @@
"jsqr": "^1.4.0",
"lucide-react-native": "^0.487.0",
"nativewind": "^4.2.6",
+ "prism-react-renderer": "^2.4.1",
+ "prismjs": "^1.29.0",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.2",
diff --git a/apps/mobile/tailwind.config.js b/apps/mobile/tailwind.config.js
index d0b7301..3b13227 100644
--- a/apps/mobile/tailwind.config.js
+++ b/apps/mobile/tailwind.config.js
@@ -1,6 +1,8 @@
const plugin = require('tailwindcss/plugin')
const { hairlineWidth } = require('nativewind/theme')
+const nativewindOs = process.env.NATIVEWIND_OS
+
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
@@ -58,7 +60,7 @@ module.exports = {
},
fontFamily: {
sans: ["Manrope_400Regular"],
- mono: ["Menlo", "monospace"],
+ mono: nativewindOs === 'ios' ? ['Menlo'] : ['monospace'],
},
borderWidth: {
hairline: hairlineWidth(),
diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json
index 9acfd07..5aa0440 100644
--- a/apps/web/drizzle/meta/_journal.json
+++ b/apps/web/drizzle/meta/_journal.json
@@ -6,7 +6,7 @@
"idx": 0,
"version": "7",
"when": 1787071226446,
- "tag": "0000_spicy_stephen_strange",
+ "tag": "0000_dodo_billing",
"breakpoints": true
}
]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 9c23031..1aa3d8b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -128,6 +128,12 @@ importers:
nativewind:
specifier: ^4.2.6
version: 4.2.6(react-native-reanimated@4.5.1(react-native-worklets@0.10.1(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3))(react@19.2.3)(tailwindcss@3.4.17)
+ prism-react-renderer:
+ specifier: ^2.4.1
+ version: 2.4.1(react@19.2.3)
+ prismjs:
+ specifier: ^1.29.0
+ version: 1.30.0
react:
specifier: 19.2.3
version: 19.2.3
@@ -9454,6 +9460,11 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
+ prism-react-renderer@2.4.1:
+ resolution: {integrity: sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==}
+ peerDependencies:
+ react: '>=16.0.0'
+
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -21480,8 +21491,13 @@ snapshots:
dependencies:
parse-ms: 4.0.0
- prismjs@1.30.0:
- optional: true
+ prism-react-renderer@2.4.1(react@19.2.3):
+ dependencies:
+ '@types/prismjs': 1.26.6
+ clsx: 2.1.1
+ react: 19.2.3
+
+ prismjs@1.30.0: {}
proc-log@4.2.0: {}