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
15 changes: 12 additions & 3 deletions apps/mobile/app/(tabs)/user.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -151,10 +154,16 @@ export default function UserPage() {
<View className="h-12 w-12 rounded-full bg-primary items-center justify-center">
<UserIcon size={24} color={THEME[theme].background} />
</View>
<View className="flex-1">
<Text className="text-base font-medium">{user.email}</Text>
<View className="flex-1 min-w-0">
<Text className="text-base font-medium" numberOfLines={1}>{user.email}</Text>
<Text className="text-sm text-muted-foreground capitalize">{user.tier} tier</Text>
</View>
{user.tier === "free" && (
<Button size="sm" onPress={openPricing}>
<Sparkles size={16} color={THEME[theme].background} />
<Text>Upgrade</Text>
</Button>
)}
<Button variant="ghost" size="sm" onPress={handleLogout}>
<LogOut size={18} color={THEME[theme].mutedForeground} />
</Button>
Expand Down
49 changes: 47 additions & 2 deletions apps/mobile/app/project/[projectId]/[sessionId]/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
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()
Expand All @@ -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<string, FileDiff>()
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])

Expand Down
87 changes: 87 additions & 0 deletions apps/mobile/components/code-block.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
className="overflow-hidden rounded-lg border border-border/60"
style={{ backgroundColor: prismTheme.plain.backgroundColor }}
>
<View className="flex-row items-center justify-between border-b border-border/40 px-3 py-1.5">
<Text className="text-xs text-muted-foreground" style={{ fontFamily: monoFont }}>{label}</Text>
<Pressable
accessibilityLabel={copied ? "Code copied" : "Copy code"}
accessibilityRole="button"
className="rounded p-1 active:opacity-60"
onPress={copyCode}
>
{copied ? (
<CheckIcon size={14} color={THEME[theme].mutedForeground} />
) : (
<CopyIcon size={14} color={THEME[theme].mutedForeground} />
)}
</Pressable>
</View>

<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ padding: 12 }}>
<Highlight code={code} language={normalizedLanguage} theme={prismTheme}>
{({ tokens, getTokenProps }) => (
<View>
{tokens.map((line, lineIndex) => (
<View key={lineIndex} className="flex-row">
{line.map((token, tokenIndex) => {
const tokenProps = getTokenProps({ token })
return (
<Text
key={tokenIndex}
style={[
codeTextStyle,
{ color: prismTheme.plain.color },
tokenProps.style as TextStyle | undefined,
]}
>
{tokenProps.children}
</Text>
)
})}
</View>
))}
</View>
)}
</Highlight>
</ScrollView>
</View>
)
}
32 changes: 18 additions & 14 deletions apps/mobile/components/memo-markdown.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
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" },
em: { fontFamily: "Manrope_400Regular" },
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" },
}

Expand All @@ -25,7 +28,7 @@ function keyedChildren(children: ReactNode[]): ReactNode {
return children.map((child, i) => <Fragment key={i}>{child}</Fragment>)
}

const customRenderer = {
const createCustomRenderer = (theme: "light" | "dark") => ({
paragraph(children: ReactNode[], styles?: ViewStyle) {
return <Text selectable={false} style={styles}>{keyedChildren(children)}</Text>
},
Expand All @@ -36,7 +39,7 @@ const customRenderer = {
return <Text selectable={false} style={styles}>{Array.isArray(text) ? keyedChildren(text) : text}</Text>
},
code(text: string, language?: string, containerStyle?: ViewStyle, textStyle?: TextStyle) {
return <View style={containerStyle}><Text selectable={false} style={textStyle}>{text}</Text></View>
return <CodeBlock text={text} language={language} theme={theme} />
},
hr(styles?: ViewStyle) {
return <View style={styles} />
Expand Down Expand Up @@ -83,11 +86,12 @@ const customRenderer = {
table(header: ReactNode[][], rows: ReactNode[][][], tableStyle?: ViewStyle, rowStyle?: ViewStyle, cellStyle?: ViewStyle) {
return <View style={tableStyle}><Text selectable={false}>Table</Text></View>
},
}
})

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<ReturnType<typeof setTimeout> | null>(null)
const customRenderer = useMemo(() => createCustomRenderer(theme), [theme])

useEffect(() => {
if (streaming) {
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion apps/mobile/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MemoMarkdown key={part.id ?? index} streaming={streaming}>{part.text}</MemoMarkdown>
return <MemoMarkdown key={part.id ?? index} theme={theme} streaming={streaming}>{part.text}</MemoMarkdown>
case "reasoning":
return null
case "tool-invocation":
Expand Down
5 changes: 3 additions & 2 deletions apps/mobile/lib/diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ function normalizeFileDiff(value: unknown): FileDiff | null {
}
}

export async function fetchSessionDiffs(url: string, token: string, sessionId: string): Promise<FileDiff[]> {
export async function fetchSessionDiffs(url: string, token: string, sessionId: string, messageID?: string): Promise<FileDiff[]> {
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),
Expand Down
73 changes: 73 additions & 0 deletions apps/mobile/lib/prism.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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<string, string> = {
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 }
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion apps/mobile/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -58,7 +60,7 @@ module.exports = {
},
fontFamily: {
sans: ["Manrope_400Regular"],
mono: ["Menlo", "monospace"],
mono: nativewindOs === 'ios' ? ['Menlo'] : ['monospace'],
},
borderWidth: {
hairline: hairlineWidth(),
Expand Down
Loading
Loading