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: {}