From afff40b7b4fd98e330cb0f3dd62d39bc6657d9e5 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 26 Aug 2026 00:39:19 +0530 Subject: [PATCH 1/4] feat(mobile): add Android home screen widgets for session status and usage Introduces Small/Medium/Large widgets via react-native-android-widget that show connection status, active session, per-project usage, and a 7-day output-token bar chart. Adds daily usage history to opencode-stats.store and persists the questions store so pending approvals surface in the widget. --- apps/mobile/CHANGELOG.md | 6 + apps/mobile/app.config.js | 37 +++++ apps/mobile/app/_layout.tsx | 29 ++++ apps/mobile/lib/widget-data.ts | 183 ++++++++++++++++++++++ apps/mobile/lib/widget-task.tsx | 43 +++++ apps/mobile/package.json | 1 + apps/mobile/store/opencode-stats.store.ts | 43 +++++ apps/mobile/store/questions.store.ts | 64 ++++---- apps/mobile/widgets/LargeWidget.tsx | 78 +++++++++ apps/mobile/widgets/MediumWidget.tsx | 70 +++++++++ apps/mobile/widgets/SmallWidget.tsx | 39 +++++ apps/mobile/widgets/shared.tsx | 113 +++++++++++++ pnpm-lock.yaml | 22 ++- 13 files changed, 699 insertions(+), 29 deletions(-) create mode 100644 apps/mobile/lib/widget-data.ts create mode 100644 apps/mobile/lib/widget-task.tsx create mode 100644 apps/mobile/widgets/LargeWidget.tsx create mode 100644 apps/mobile/widgets/MediumWidget.tsx create mode 100644 apps/mobile/widgets/SmallWidget.tsx create mode 100644 apps/mobile/widgets/shared.tsx diff --git a/apps/mobile/CHANGELOG.md b/apps/mobile/CHANGELOG.md index 378aabe..704d497 100644 --- a/apps/mobile/CHANGELOG.md +++ b/apps/mobile/CHANGELOG.md @@ -1,3 +1,9 @@ # @crosscode/mobile +## Unreleased + +### Added +- Android home screen widgets (Small / Medium / Large) showing connection status, active session, per-project usage, and a 7-day output-token bar chart. Powered by `react-native-android-widget`. +- Daily usage history tracking (`dailyHistory`) in `opencode-stats.store` to support widget charts. + ## 1.0.0 diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index dafc66b..f8b3ae0 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -10,6 +10,43 @@ module.exports = ({ config }) => { "expo-image", "expo-router", "expo-status-bar", + [ + "react-native-android-widget", + { + widgets: [ + { + name: "SmallWidget", + label: "CrossCode Status", + description: "Connection status and active session", + minWidth: "110dp", + minHeight: "40dp", + targetCellWidth: 2, + targetCellHeight: 1, + resizeMode: "horizontal", + }, + { + name: "MediumWidget", + label: "CrossCode Session", + description: "Session status, today's usage and 7-day chart", + minWidth: "250dp", + minHeight: "110dp", + targetCellWidth: 4, + targetCellHeight: 2, + resizeMode: "horizontal|vertical", + }, + { + name: "LargeWidget", + label: "CrossCode Projects", + description: "Per-project usage with mini charts", + minWidth: "250dp", + minHeight: "180dp", + targetCellWidth: 4, + targetCellHeight: 3, + resizeMode: "horizontal|vertical", + }, + ], + }, + ], [ "expo-build-properties", { diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 644b7ed..621083c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -19,10 +19,17 @@ import * as SplashScreen from "expo-splash-screen" import { NAV_THEME } from "@/lib/theme" import { useNotificationRouting } from "@/lib/notifications" +import { registerCrossCodeWidgets, refreshWidgets } from "@/lib/widget-task" import { useSettings } from "@/store/settings.store" +import { useSessions } from "@/store/sessions.store" +import { useConnections } from "@/store/connection.store" +import { useOpencodeStats } from "@/store/opencode-stats.store" +import { useQuestions } from "@/store/questions.store" SplashScreen.preventAutoHideAsync() +registerCrossCodeWidgets() + export default function RootLayout() { const router = useRouter() const segments = useSegments() @@ -49,6 +56,28 @@ export default function RootLayout() { } }, [fontsLoaded, hasCompletedOnboarding, router, segments, settingsHydrated]) + React.useEffect(() => { + let timer: ReturnType | undefined + const schedule = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + refreshWidgets() + }, 800) + } + const unsubSessions = useSessions.subscribe(schedule) + const unsubConnections = useConnections.subscribe(schedule) + const unsubStats = useOpencodeStats.subscribe(schedule) + const unsubQuestions = useQuestions.subscribe(schedule) + refreshWidgets() + return () => { + if (timer) clearTimeout(timer) + unsubSessions() + unsubConnections() + unsubStats() + unsubQuestions() + } + }, []) + React.useEffect(() => { if (fontsLoaded && settingsHydrated) { SplashScreen.hideAsync() diff --git a/apps/mobile/lib/widget-data.ts b/apps/mobile/lib/widget-data.ts new file mode 100644 index 0000000..328bb48 --- /dev/null +++ b/apps/mobile/lib/widget-data.ts @@ -0,0 +1,183 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" +import { DAILY_HISTORY_LIMIT, type DailyBucket, todayKey } from "@/store/opencode-stats.store" + +const CONNECTIONS_KEY = "crosscode-connections" +const SESSIONS_KEY = "crosscode-sessions" +const STATS_KEY = "crosscode-opencode-stats" +const QUESTIONS_KEY = "crosscode-questions" + +type Persisted = { state: T } + +interface Connection { + id: string + url: string + name: string + healthy?: boolean | null +} + +interface SessionSummary { + additions: number + deletions: number + files: number +} + +interface Session { + id: string + projectID: string + directory: string + title: string + agent?: string + time: { created: number; updated: number } + summary?: SessionSummary +} + +interface ProjectStats { + projectId: string + projectName: string + responseCount: number + totalInputTokens: number + totalOutputTokens: number + totalCost: number + lastResponseAt: string | null + dailyHistory?: Record +} + +async function readPersisted(key: string): Promise { + try { + const raw = await AsyncStorage.getItem(key) + if (!raw) return undefined + const parsed = JSON.parse(raw) as Persisted + return parsed?.state + } catch { + return undefined + } +} + +function last7Days(): string[] { + const days: string[] = [] + const now = new Date() + for (let i = 6; i >= 0; i--) { + const d = new Date(now) + d.setDate(now.getDate() - i) + days.push(d.toISOString().slice(0, 10)) + } + return days +} + +export interface ProjectWidgetData { + id: string + name: string + responseCount: number + inputTokens: number + outputTokens: number + cost: number + lastResponseAt: string | null + /** Output tokens per day for the trailing 7 days (oldest -> newest). */ + weekOutputTokens: number[] +} + +export interface WidgetData { + connected: boolean + serverName: string + hasSession: boolean + sessionTitle: string + sessionAgent: string + sessionAdditions: number + sessionDeletions: number + sessionFiles: number + sessionUpdatedAt: number | null + pendingQuestions: number + todayInputTokens: number + todayOutputTokens: number + todayCost: number + weekOutputTokens: number[] + projects: ProjectWidgetData[] +} + +export async function buildWidgetData(): Promise { + const [connections, sessions, stats, questions] = await Promise.all([ + readPersisted<{ connections: Connection[]; current: string | null }>(CONNECTIONS_KEY), + readPersisted(SESSIONS_KEY), + readPersisted>(STATS_KEY), + readPersisted<{ questionsBySession: Record }>(QUESTIONS_KEY), + ]) + + const currentId = connections?.current + const current = + connections?.connections.find((c) => c.id === currentId) ?? + connections?.connections[0] + const connected = Boolean(current && current.healthy !== false && current.url) + + const allSessions = sessions ?? [] + const activeSession = allSessions.length + ? [...allSessions].sort((a, b) => b.time.updated - a.time.updated)[0] + : undefined + + const pendingQuestions = questions + ? Object.values(questions.questionsBySession ?? {}).reduce( + (sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), + 0 + ) + : 0 + + const days = last7Days() + const today = todayKey() + const projectsMap = stats ?? {} + const projects: ProjectWidgetData[] = Object.values(projectsMap) + .map((p) => ({ + id: p.projectId, + name: p.projectName, + responseCount: p.responseCount, + inputTokens: p.totalInputTokens, + outputTokens: p.totalOutputTokens, + cost: p.totalCost, + lastResponseAt: p.lastResponseAt, + weekOutputTokens: days.map((d) => p.dailyHistory?.[d]?.outputTokens ?? 0), + })) + .sort((a, b) => (b.lastResponseAt ?? "").localeCompare(a.lastResponseAt ?? "")) + .slice(0, 5) + + const globalHistory: Record = {} + for (const p of Object.values(projectsMap)) { + for (const [day, bucket] of Object.entries(p.dailyHistory ?? {})) { + const agg = globalHistory[day] ?? { + inputTokens: 0, + outputTokens: 0, + cost: 0, + responses: 0, + } + agg.inputTokens += bucket.inputTokens + agg.outputTokens += bucket.outputTokens + agg.cost += bucket.cost + agg.responses += bucket.responses + globalHistory[day] = agg + } + } + + const todayBucket = globalHistory[today] ?? { + inputTokens: 0, + outputTokens: 0, + cost: 0, + responses: 0, + } + + return { + connected: Boolean(connected), + serverName: current?.name ?? "", + hasSession: Boolean(activeSession), + sessionTitle: activeSession?.title ?? "", + sessionAgent: activeSession?.agent ?? "", + sessionAdditions: activeSession?.summary?.additions ?? 0, + sessionDeletions: activeSession?.summary?.deletions ?? 0, + sessionFiles: activeSession?.summary?.files ?? 0, + sessionUpdatedAt: activeSession?.time.updated ?? null, + pendingQuestions, + todayInputTokens: todayBucket.inputTokens, + todayOutputTokens: todayBucket.outputTokens, + todayCost: todayBucket.cost, + weekOutputTokens: days.map((d) => globalHistory[d]?.outputTokens ?? 0), + projects, + } +} + +export const WIDGET_DAILY_HISTORY_LIMIT = DAILY_HISTORY_LIMIT diff --git a/apps/mobile/lib/widget-task.tsx b/apps/mobile/lib/widget-task.tsx new file mode 100644 index 0000000..e2f29d9 --- /dev/null +++ b/apps/mobile/lib/widget-task.tsx @@ -0,0 +1,43 @@ +import { registerWidgetTaskHandler, requestWidgetUpdate } from "react-native-android-widget" +import { buildWidgetData } from "@/lib/widget-data" +import SmallWidget from "@/widgets/SmallWidget" +import MediumWidget from "@/widgets/MediumWidget" +import LargeWidget from "@/widgets/LargeWidget" + +type WidgetData = Awaited> + +const WIDGETS: Record React.ReactElement> = { + SmallWidget, + MediumWidget, + LargeWidget, +} + +let registered = false + +export function registerCrossCodeWidgets() { + if (registered) return + registered = true + + registerWidgetTaskHandler(async ({ widgetInfo, widgetAction, renderWidget }) => { + if (widgetAction === "WIDGET_DELETED") return + + const Component = WIDGETS[widgetInfo.widgetName] + if (!Component) return + + const data = await buildWidgetData() + renderWidget( as never) + }) +} + +export async function refreshWidgets() { + const data = await buildWidgetData() + await Promise.all( + Object.keys(WIDGETS).map((name) => { + const Component = WIDGETS[name] + return requestWidgetUpdate({ + widgetName: name, + renderWidget: () => as never, + }).catch(() => {}) + }) + ) +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index a2d5dd9..7839434 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -43,6 +43,7 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.86.2", + "react-native-android-widget": "^0.22.1", "react-native-css-interop": "^0.2.6", "react-native-gesture-handler": "^2.32.0", "react-native-marked": "^8.1.0", diff --git a/apps/mobile/store/opencode-stats.store.ts b/apps/mobile/store/opencode-stats.store.ts index 7798c24..e3401e4 100644 --- a/apps/mobile/store/opencode-stats.store.ts +++ b/apps/mobile/store/opencode-stats.store.ts @@ -2,6 +2,15 @@ import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" import { createJSONStorage, persist } from "zustand/middleware" +export interface DailyBucket { + inputTokens: number + outputTokens: number + cost: number + responses: number +} + +export const DAILY_HISTORY_LIMIT = 30 + export interface ProjectStats { projectId: string projectName: string @@ -11,6 +20,22 @@ export interface ProjectStats { totalCost: number lastResponseAt: string | null firstResponseAt: string | null + /** Per-day usage keyed by YYYY-MM-DD (UTC), capped at DAILY_HISTORY_LIMIT days. */ + dailyHistory: Record +} + +export function todayKey(date = new Date()): string { + return date.toISOString().slice(0, 10) +} + +function pruneHistory(history: Record): Record { + const keys = Object.keys(history).sort() + if (keys.length <= DAILY_HISTORY_LIMIT) return history + const next: Record = {} + for (const key of keys.slice(keys.length - DAILY_HISTORY_LIMIT)) { + next[key] = history[key] + } + return next } type OpencodeStatsStore = { @@ -38,6 +63,23 @@ export const useOpencodeStats = create()( const existing = state.projects[projectId] const now = new Date().toISOString() + const key = todayKey() + const prevBucket = existing?.dailyHistory?.[key] ?? { + inputTokens: 0, + outputTokens: 0, + cost: 0, + responses: 0, + } + const dailyHistory = pruneHistory({ + ...(existing?.dailyHistory ?? {}), + [key]: { + inputTokens: prevBucket.inputTokens + inputTokens, + outputTokens: prevBucket.outputTokens + outputTokens, + cost: prevBucket.cost + cost, + responses: prevBucket.responses + 1, + }, + }) + const updated: ProjectStats = { projectId, projectName, @@ -47,6 +89,7 @@ export const useOpencodeStats = create()( totalCost: (existing?.totalCost ?? 0) + cost, lastResponseAt: now, firstResponseAt: existing?.firstResponseAt ?? now, + dailyHistory, } return { diff --git a/apps/mobile/store/questions.store.ts b/apps/mobile/store/questions.store.ts index 02da087..84047e3 100644 --- a/apps/mobile/store/questions.store.ts +++ b/apps/mobile/store/questions.store.ts @@ -1,4 +1,6 @@ +import AsyncStorage from "@react-native-async-storage/async-storage" import { create } from "zustand" +import { createJSONStorage, persist } from "zustand/middleware" export type QuestionOption = { label: string @@ -32,31 +34,39 @@ type QuestionsStore = { clearSessionQuestions: (sessionId: string) => void } -export const useQuestions = create()((set) => ({ - questionsBySession: {}, - - setQuestions: (sessionId, questions) => - set((state) => ({ - questionsBySession: { - ...state.questionsBySession, - [sessionId]: questions, - }, - })), - - removeQuestion: (sessionId, requestId) => - set((state) => ({ - questionsBySession: { - ...state.questionsBySession, - [sessionId]: (state.questionsBySession[sessionId] ?? []).filter( - (q) => q.id !== requestId - ), - }, - })), - - clearSessionQuestions: (sessionId) => - set((state) => { - const next = { ...state.questionsBySession } - delete next[sessionId] - return { questionsBySession: next } +export const useQuestions = create()( + persist( + (set) => ({ + questionsBySession: {}, + + setQuestions: (sessionId, questions) => + set((state) => ({ + questionsBySession: { + ...state.questionsBySession, + [sessionId]: questions, + }, + })), + + removeQuestion: (sessionId, requestId) => + set((state) => ({ + questionsBySession: { + ...state.questionsBySession, + [sessionId]: (state.questionsBySession[sessionId] ?? []).filter( + (q) => q.id !== requestId + ), + }, + })), + + clearSessionQuestions: (sessionId) => + set((state) => { + const next = { ...state.questionsBySession } + delete next[sessionId] + return { questionsBySession: next } + }), }), -})) + { + name: "crosscode-questions", + storage: createJSONStorage(() => AsyncStorage), + } + ) +) diff --git a/apps/mobile/widgets/LargeWidget.tsx b/apps/mobile/widgets/LargeWidget.tsx new file mode 100644 index 0000000..9d5f6f4 --- /dev/null +++ b/apps/mobile/widgets/LargeWidget.tsx @@ -0,0 +1,78 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" +import { BarChart, COLORS, StatusRow, formatCost, formatNumber } from "./shared" +import type { ProjectWidgetData } from "../lib/widget-data" + +function ProjectRow({ project }: { project: ProjectWidgetData }) { + return ( + + + + + + + + + + + ) +} + +export default function LargeWidget(props: WidgetData) { + const projects = props.projects.slice(0, 3) + return ( + + + + + + + + + + + + + + + {projects.length === 0 ? ( + + ) : ( + + {projects.map((p) => ( + + ))} + + )} + + ) +} diff --git a/apps/mobile/widgets/MediumWidget.tsx b/apps/mobile/widgets/MediumWidget.tsx new file mode 100644 index 0000000..e3e8fd4 --- /dev/null +++ b/apps/mobile/widgets/MediumWidget.tsx @@ -0,0 +1,70 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" +import { BarChart, COLORS, StatusRow, formatCost, formatNumber, formatRelativeTime } from "./shared" + +export default function MediumWidget(props: WidgetData) { + return ( + + + + {props.hasSession ? ( + + + + + ) : ( + + )} + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/widgets/SmallWidget.tsx b/apps/mobile/widgets/SmallWidget.tsx new file mode 100644 index 0000000..d734a09 --- /dev/null +++ b/apps/mobile/widgets/SmallWidget.tsx @@ -0,0 +1,39 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" +import { COLORS, StatusRow, formatRelativeTime } from "./shared" + +export default function SmallWidget(props: WidgetData) { + return ( + + + {props.hasSession ? ( + + + + + ) : ( + + )} + + ) +} diff --git a/apps/mobile/widgets/shared.tsx b/apps/mobile/widgets/shared.tsx new file mode 100644 index 0000000..cd7f70f --- /dev/null +++ b/apps/mobile/widgets/shared.tsx @@ -0,0 +1,113 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { ColorProp } from "react-native-android-widget" +import type { WidgetData } from "../lib/widget-data" + +export const COLORS = { + bg: "#0b0b0f", + surface: "#16161d", + border: "#26262f", + text: "#fafafa", + muted: "#a1a1aa", + connected: "#4ade80", + disconnected: "#f87171", + pending: "#fbbf24", + bar: "#7dd3fc", +} as const + +export function formatNumber(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M` + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k` + return `${Math.round(n)}` +} + +export function formatCost(n: number): string { + if (n <= 0) return "$0.00" + if (n < 1) return `$${n.toFixed(3)}` + return `$${n.toFixed(2)}` +} + +export function formatRelativeTime(ts: number | null): string { + if (!ts) return "" + const diff = Date.now() - ts + const mins = Math.floor(diff / 60000) + if (mins < 1) return "just now" + if (mins < 60) return `${mins}m ago` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + +export function BarChart({ + values, + height, + color = COLORS.bar, +}: { + values: number[] + height: number + color?: ColorProp +}) { + const max = Math.max(1, ...values) + return ( + + {values.map((v, i) => { + const barHeight = Math.max(2, Math.round((v / max) * (height - 2))) + return ( + + + + ) + })} + + ) +} + +export function StatusRow({ data }: { data: WidgetData }) { + const label = data.connected + ? data.serverName || "Connected" + : "Not connected" + return ( + + + + {data.pendingQuestions > 0 ? ( + + ) : null} + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a2b9ea..f4f04a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: react-native: specifier: 0.86.2 version: 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-native-android-widget: + specifier: ^0.22.1 + version: 0.22.1(expo@57.0.11)(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-css-interop: specifier: ^0.2.6 version: 0.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) @@ -9635,6 +9638,16 @@ packages: react-is@19.2.8: resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + react-native-android-widget@0.22.1: + resolution: {integrity: sha512-BBMuqShRrxV23Z09Rn5zNHJaPegCnH3DhhZQgXbhw2GMh45Gez0RbwKTIWHftuW4nLrqWy6esz8XwecUZLiZKA==} + peerDependencies: + expo: '>=54.0.0' + react: '*' + react-native: '*' + peerDependenciesMeta: + expo: + optional: true + react-native-css-interop@0.2.6: resolution: {integrity: sha512-uXad6EWqufupnRjrba2De30tiAx26kLEcwSV3eSLVBwpfYeOnrpDc9k6kHk6SBNbmHHlE5sc9vsfKdVRS9msYg==} engines: {node: '>=18'} @@ -15426,9 +15439,7 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.74.89': optional: true @@ -21719,6 +21730,13 @@ snapshots: react-is@19.2.8: {} + react-native-android-widget@0.22.1(expo@57.0.11)(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): + dependencies: + 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) + optionalDependencies: + expo: 57.0.11(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@57.0.8)(expo-router@57.0.11)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(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)(typescript@6.0.3) + react-native-css-interop@0.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): dependencies: '@babel/helper-module-imports': 7.29.7 From 18f5917906b0a34f43780377971fac5822d34db1 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 26 Aug 2026 00:46:58 +0530 Subject: [PATCH 2/4] feat(mobile): add about screen accessible from branding icon Add a dedicated About page with app branding, support/GitHub source links, social links, and an end credit. The home screen branding icon now navigates to this screen. Co-Authored-By: opencode --- apps/mobile/app/(tabs)/index.tsx | 12 +-- apps/mobile/app/about.tsx | 128 +++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/app/about.tsx diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 77446ff..fa29dc6 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -382,11 +382,13 @@ export default function HomeScreen() { - + router.push("/about")} hitSlop={8} className="active:opacity-60"> + + router.push("/notifications")} className="p-2"> diff --git a/apps/mobile/app/about.tsx b/apps/mobile/app/about.tsx new file mode 100644 index 0000000..e7ceb01 --- /dev/null +++ b/apps/mobile/app/about.tsx @@ -0,0 +1,128 @@ +import * as React from "react" +import { Image, Linking, Pressable, ScrollView, View } from "react-native" +import { useSafeAreaInsets } from "react-native-safe-area-context" + +import { Text } from "@/components/ui/text" +import { useColorScheme } from "nativewind" +import { useRouter } from "expo-router" +import { THEME } from "@/lib/theme" + +import ArrowLeft from "lucide-react-native/dist/esm/icons/arrow-left" +import Coffee from "lucide-react-native/dist/esm/icons/coffee" +import Github from "lucide-react-native/dist/esm/icons/github" +import Instagram from "lucide-react-native/dist/esm/icons/instagram" +import Twitter from "lucide-react-native/dist/esm/icons/twitter" +import MessageCircle from "lucide-react-native/dist/esm/icons/message-circle" + +const SUPPORT_URL = "https://buymeacoffee.com/snhsish" +const SOURCE_URL = "https://github.com/snhsish/crosscode" +const CREDIT_URL = "https://sish.work" + +const SOCIAL_LINKS: { key: string; label: string; url: string; Icon: React.ComponentType<{ size: number; color: string }> }[] = [ + { key: "instagram", label: "Instagram", url: "https://instagram.com/snehasish", Icon: Instagram }, + { key: "github", label: "GitHub", url: "https://github.com/snhsish", Icon: Github }, + { key: "x", label: "X", url: "https://x.com/snhsish", Icon: Twitter }, + { key: "discord", label: "Discord", url: "https://discord.gg/K6k6ebkJkx", Icon: MessageCircle }, +] + +export default function AboutScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const theme = useColorScheme().colorScheme ?? "light" + const openLink = React.useCallback((url: string) => Linking.openURL(url), []) + + return ( + + + router.back()} + className="p-2 -ml-2 active:opacity-60" + hitSlop={12} + > + + + + + + + + + CrossCode + + An Opencode remote mobile client + + + + + + openLink(SUPPORT_URL)} + className="flex-row items-center gap-4 rounded-xl bg-muted/50 p-4 active:bg-muted/70" + > + + + + + Support project + buymeacoffee.com/snhsish + + + + openLink(SOURCE_URL)} + className="flex-row items-center gap-4 rounded-xl bg-muted/50 p-4 active:bg-muted/70" + > + + + + + GitHub Source + github.com/snhsish/crosscode + + + + + + + Social + + + {SOCIAL_LINKS.map(({ key, label, url, Icon }) => ( + openLink(url)} + className="flex-1 items-center gap-2 rounded-xl bg-muted/50 py-4 active:bg-muted/70" + hitSlop={8} + > + + + + {label} + + ))} + + + + + + Made by{" "} + openLink(CREDIT_URL)} + > + @snhsish + + + + + + ) +} From a30d61ed03af80c6c9109be5d4e00ce80fd151f5 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 26 Aug 2026 01:39:10 +0530 Subject: [PATCH 3/4] fix(mobile): track elapsed time for the full response turn Anchor the working indicator to the first assistant message of the current turn so the timer reflects the entire response instead of resetting per message part, and show a final "Worked for" duration once streaming ends. --- .../project/[projectId]/[sessionId]/index.tsx | 57 ++++++++++++++----- apps/mobile/components/typing-animation.tsx | 13 +++-- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index b19ad15..fd03426 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -714,24 +714,55 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const keyExtractor = useCallback((item: Message) => item.id, []) - const workingStartAt = useMemo(() => { - if (!isStreaming) return null - const ordered = [...rawMessages].reverse() - const streamingMsg = ordered.find((m) => m.role === "assistant" && !m.time?.completed) - if (streamingMsg) return streamingMsg.time.created - const lastUser = [...ordered].reverse().find((m) => m.role === "user") - return lastUser?.time?.created ?? null - }, [isStreaming, rawMessages]) + // Compute the current response "turn": all messages after the last user message. + // The group start is anchored to the first assistant message of the turn so the + // timer reflects the entire response rather than resetting per message part. + const turn = useMemo(() => { + const sorted = [...rawMessages].sort( + (a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0) + ) + let lastUserIdx = -1 + for (let i = sorted.length - 1; i >= 0; i--) { + if (sorted[i].role === "user") { + lastUserIdx = i + break + } + } + if (lastUserIdx === -1) return null + + const after = sorted.slice(lastUserIdx + 1) + const assistantMsgs = after.filter((m) => m.role === "assistant") + + if (assistantMsgs.length === 0) { + const startAt = sorted[lastUserIdx].time?.created ?? null + return startAt == null ? null : { startAt, endAt: null as number | null } + } + + const startAt = assistantMsgs[0].time?.created ?? null + if (startAt == null) return null + + let endAt: number | null = null + if (!isStreaming) { + let max = 0 + for (const m of after) { + const t = m.time?.completed ?? m.time?.created ?? 0 + if (t > max) max = t + } + endAt = max || null + } + + return { startAt, endAt } + }, [rawMessages, isStreaming]) // The list is inverted, so the header renders visually below the newest message const StreamingIndicator = useMemo(() => { - if (!isStreaming || workingStartAt == null) return null + if (!turn || turn.startAt == null) return null return ( - + ) - }, [isStreaming, workingStartAt]) + }, [turn]) const ListFooterComponent = useMemo(() => { if (!isLoadingMore) return null @@ -860,9 +891,9 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi )} - {isStreaming && messages.length === 0 && workingStartAt != null && ( + {turn && turn.startAt != null && messages.length === 0 && ( - + )} diff --git a/apps/mobile/components/typing-animation.tsx b/apps/mobile/components/typing-animation.tsx index 6d441c9..fbc3c00 100644 --- a/apps/mobile/components/typing-animation.tsx +++ b/apps/mobile/components/typing-animation.tsx @@ -47,22 +47,25 @@ function formatElapsedTime(totalSeconds: number): string { return `${minutes}m ${seconds}s` } -export function WorkingIndicator({ startedAt }: { startedAt: number }) { +export function WorkingIndicator({ startedAt, endedAt }: { startedAt: number; endedAt?: number | null }) { const [now, setNow] = useState(() => Date.now()) + const done = endedAt != null + const active = done ? endedAt! : now useEffect(() => { + if (done) return setNow(Date.now()) const id = setInterval(() => setNow(Date.now()), 1000) return () => clearInterval(id) - }, [startedAt]) + }, [done, startedAt]) - const totalSeconds = Math.max(0, Math.floor((now - startedAt) / 1000)) + const totalSeconds = Math.max(0, Math.floor((active - startedAt) / 1000)) return ( - + {done ? null : } - Working for {formatElapsedTime(totalSeconds)} + {done ? "Worked for " : "Working for "}{formatElapsedTime(totalSeconds)} ) From 14747ec16e3f57844af5305670cd50fccaab4d85 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 26 Aug 2026 01:39:10 +0530 Subject: [PATCH 4/4] docs: add per-OS setup guides and full login workflow Include macOS/Linux/Windows (incl. WSL2) install steps for OpenCode and the CrossCode CLI, a step-by-step zero-to-connected walkthrough, and the `crosscode login` step for the dedicated connect.crosscode.site tunnel. --- README.md | 237 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 220 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 49e719d..636348f 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,249 @@ # CrossCode -Control your PC's [OpenCode](https://opencode.ai) — the terminal AI coding agent — from your phone, from anywhere in the world. Free, open source, and private: your code never touches CrossCode's servers, because there are none. +Control your PC's [OpenCode](https://opencode.ai) (the terminal AI coding agent) from your phone, from anywhere in the world. Free, open source, and private. Your code never touches CrossCode's servers, because there are none. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![npm](https://img.shields.io/npm/v/crosscode)](https://www.npmjs.com/package/crosscode) [![Node.js >=20](https://img.shields.io/badge/node-%3E%3D20-brightgreen)](https://nodejs.org) -## Quick Start +## What is CrossCode? -### 1. On your PC +CrossCode streams a live OpenCode session from your computer to your phone over a secure tunnel. You get real-time chat, streaming responses, inline diffs, tool-call approvals, a Git panel, voice input, push notifications, and more. Your code is never uploaded to a cloud. -Open a terminal in your project directory and run: +There are two ways to connect: + +| Mode | Tunnel | Account needed? | URL | +|---|---|---|---| +| Free | Cloudflare or ngrok | No | Random, changes each session | +| Dedicated | CrossCode Tunnel | Yes (free or paid login) | Stable `*.connect.crosscode.site` | + +We recommend logging in. It gives you a stable, dedicated URL so you don't have to re-scan a new QR code every time you restart. + +## Prerequisites + +- A computer (Windows, macOS, or Linux) with [OpenCode](https://opencode.ai) installed +- Node.js 20 or newer (only needed for the CLI wrapper; OpenCode itself is a standalone binary) +- A phone with the CrossCode app ([Android](https://crosscode.site/download); iOS coming soon) +- For free tunnels: `cloudflared` (the CLI will guide you if it's missing) or `ngrok` + +--- + +## Install OpenCode (by operating system) + +CrossCode needs a running `opencode serve` on your machine. Install OpenCode first. + +### macOS + +```bash +# Recommended (always up to date) +brew install anomalyco/tap/opencode + +# Or the official install script +curl -fsSL https://opencode.ai/install | bash +``` + +### Linux + +```bash +# Debian/Ubuntu or any distro with Homebrew +brew install anomalyco/tap/opencode + +# Arch (stable) +sudo pacman -S opencode + +# Arch (latest from AUR) +paru -S opencode-bin + +# Or the official install script (any distro) +curl -fsSL https://opencode.ai/install | bash +``` + +### Windows + +Windows has two good options. + +**Option A - WSL2 (recommended for the best experience)** + +```powershell +# 1. Install WSL2, then open your WSL terminal and run: +curl -fsSL https://opencode.ai/install | bash +``` + +**Option B - Native Windows** + +```powershell +# Chocolatey +choco install opencode + +# or Scoop +scoop install opencode + +# or npm +npm install -g opencode-ai +``` + +If you use WSL, run CrossCode inside WSL too (see the Windows workflow below). Windows and WSL have separate PATHs and home directories. + +Verify the install on any OS: + +```bash +opencode --version +``` + +--- + +## Install the CrossCode CLI (by operating system) + +The CLI (`crosscode`) does all the setup for you. It starts `opencode serve`, opens a tunnel, and prints a QR code. + +### macOS or Linux + +```bash +# Run instantly (no install needed) +npx crosscode + +# Or install globally +npm install -g crosscode +``` + +### Windows (native) + +```powershell +# Run instantly +npx crosscode + +# Or install globally +npm install -g crosscode +``` + +### Windows (WSL2) + +Run the same commands inside your WSL terminal. Node.js must be installed in WSL: + +```bash +# Install Node.js in WSL if you don't have it +curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - +sudo apt-get install -y nodejs + +# Then run CrossCode +npx crosscode +``` + +Tip: `npx crosscode` always uses the latest version. If you installed it globally, run `npm update -g crosscode` to upgrade. + +--- + +## Step-by-Step: From Zero to Connected + +Follow these in order. OS-specific install commands are in the sections above. + +### 1. Install OpenCode on your computer +Pick your OS in the Install OpenCode section and run the command. Verify with `opencode --version`. + +### 2. Log in for a dedicated tunnel (recommended) +Logging in gives you a stable, dedicated URL at `*.connect.crosscode.site`. You can reconnect from your phone without re-scanning a fresh QR code every session. + +```bash +npx crosscode login +``` + +1. Your browser opens `https://crosscode.site/login` +2. Log in with your email (one-time code) on the dashboard and copy your API key +3. Paste the API key back into the terminal + +Your credentials are saved to `~/.crosscode/config.json`. Check status anytime with `npx crosscode status`, or clear it with `npx crosscode logout`. + +Skipping login? That's fine. You'll use the free Cloudflare or ngrok tunnel instead. Make sure `cloudflared` or `ngrok` is installed (the CLI will tell you if it's missing and how to install it). Your tunnel URL will change each time you start. + +### 3. Start CrossCode in your project directory +Open a terminal in the folder you want to work in and run: ```bash npx crosscode ``` -This starts `opencode serve`, opens a tunnel, and prints a QR code in your terminal. (Make sure [OpenCode](https://opencode.ai) is installed first.) +This will: +- Start `opencode serve` in the current directory +- Open your tunnel (dedicated if logged in, otherwise Cloudflare or ngrok) +- Print a QR code in your terminal + +Keep this terminal open while you use your phone. + +### 4. Install the mobile app and connect +1. Install CrossCode on your phone ([Android](https://crosscode.site/download); iOS coming soon) +2. Open the app and scan the QR code shown in your terminal +3. You're connected. Start chatting with your agent + +### 5. Stay connected across restarts (optional) +Because the dedicated tunnel gives you a stable URL, you can reopen the app later and reconnect to the same session without scanning again. Just make sure `npx crosscode` is running on your PC. + +That's the whole setup. Your code never leaves your machines. CrossCode only relays the live session traffic. + +--- + +## Tunnel Options + +| Condition | Tunnel Used | +|---|---| +| Logged in (any tier) | CrossCode Tunnel (`*.connect.crosscode.site`) | +| `--ngrok` flag | ngrok | +| `--cloudflared` flag | cloudflared | +| Not logged in, no flag | cloudflared (fallback) | +| CrossCode tunnel fails | cloudflared (automatic fallback) | + +```bash +npx crosscode # auto-selected tunnel (dedicated if logged in) +npx crosscode --cloudflared # force Cloudflare tunnel +npx crosscode --ngrok # force ngrok tunnel +``` + +If you use `--ngrok`, set your auth token first: `ngrok config add-authtoken `. + +### Installing cloudflared (free tier) + +The free tunnel needs `cloudflared`. If it's missing, the CLI will detect it and guide you. You can also install it ahead of time: + +```bash +# macOS or Linux (Homebrew) +brew install cloudflared + +# Debian/Ubuntu +curl -fsSL https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o /tmp/cloudflared.deb +sudo dpkg -i /tmp/cloudflared.deb + +# Windows (native) +winget install cloudflare.cloudflared +# or: choco install cloudflared +``` + +--- + +## Interactive Controls -### 2. On your phone +While the tunnel is running: -1. Install the CrossCode app — [Android](https://crosscode.site/download) (iOS coming soon) -2. Open the app and scan the QR code -3. Start chatting with your agent +| Key | Action | +|---|---| +| `l` | Toggle log viewer (last 20 lines from CrossCode, tunnel, and OpenCode) | +| `Ctrl+C` | Graceful shutdown that kills all child processes and exits | -That's it. No account, no configuration, no shared network. +--- ## How It Works -Your phone connects to the OpenCode instance running on your machine through a secure tunnel, and streams everything back in real time — prompts, streaming responses, tool-call approvals, and file diffs. +Your phone connects to the OpenCode instance running on your machine through a secure tunnel. It streams everything back in real time: prompts, streaming responses, tool-call approvals, and file diffs. Your source code stays on your computer. The relay only forwards the encrypted session stream. ## Documentation -- [CLI Reference](docs/cli.md) — all commands, flags, and configuration -- [Development](docs/development.md) — set up and build the project locally -- [Features](docs/features.md) — what CrossCode can do -- [Tunnel Server](docs/tunnel-server/README.md) — self-hosted relay for paid users +- [CLI Reference](docs/cli.md) - all commands, flags, tunnel options, config, and environment variables +- [Development](docs/development.md) - set up and build the project locally +- [Features](docs/features.md) - what CrossCode can do +- [Tunnel Server](docs/tunnel-server/README.md) - self-hosted relay for paid users ## Contributing -Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md). +Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md). ## License -MIT — see [LICENSE](LICENSE). \ No newline at end of file +MIT. See [LICENSE](LICENSE).