From afff40b7b4fd98e330cb0f3dd62d39bc6657d9e5 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 26 Aug 2026 00:39:19 +0530 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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). From 8b574eec797ab368463526a84eb19760b54fb76c Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Wed, 26 Aug 2026 14:37:31 +0530 Subject: [PATCH 5/7] fix(cli): stabilize session identity and tunnel URLs for reliable reconnect Persist sessionToken and projectId in config so the QR token and the managed tunnel URL (connect.crosscode.site/t/) stay constant across CLI restarts. For free, unauthenticated users use a persistent named cloudflared tunnel (stable *.cfargotunnel.com hostname) instead of the ephemeral quick tunnel that rotates its URL on every (re)start. Falls back to the quick tunnel when cloudflared is not logged in. Also print the QR only once per identity and auto-restart a dropped named tunnel without changing its URL, so a returning mobile client can always reconnect. --- .changeset/stable-session-reconnect.md | 10 ++ packages/crosscode/src/cli.ts | 239 ++++++++++++++++--------- 2 files changed, 168 insertions(+), 81 deletions(-) create mode 100644 .changeset/stable-session-reconnect.md diff --git a/.changeset/stable-session-reconnect.md b/.changeset/stable-session-reconnect.md new file mode 100644 index 0000000..3070854 --- /dev/null +++ b/.changeset/stable-session-reconnect.md @@ -0,0 +1,10 @@ +--- +"crosscode": patch +--- + +Stabilize the CLI session identity and tunnel URLs so a returning mobile client (e.g. after the PC sleeps or the network drops) can always reconnect instead of being stranded by a freshly generated QR/URL. + +- Persist `sessionToken` (opencode password + QR token) and `projectId` in `~/.crosscode/config.json` so they stay constant across CLI restarts. This makes the managed tunnel URL (`connect.crosscode.site/t/`) and the QR token stable. +- For free, unauthenticated users, use a persistent **named cloudflared tunnel** (`crosscode-`) which keeps a fixed `*.cfargotunnel.com` hostname across restarts and reconnects. Falls back to the ephemeral quick tunnel (URL still changes on restart) when `cloudflared tunnel login` hasn't been run. +- Print the QR code exactly once per identity; tunnel reconnects no longer regenerate it. +- Auto-restart a dropped named cloudflared tunnel without changing its URL. diff --git a/packages/crosscode/src/cli.ts b/packages/crosscode/src/cli.ts index baac483..20d9816 100644 --- a/packages/crosscode/src/cli.ts +++ b/packages/crosscode/src/cli.ts @@ -13,11 +13,12 @@ import http from "http" import net from "net" import { encodeQrPayload } from "@crosscode/shared" import { onKeypress, cleanupKeypress } from "./keypress" -import { connectTunnel, deriveProjectId } from "./tunnel-client" +import { connectTunnel } from "./tunnel-client" import { handleGitRequest } from "./git-handler" import { waitForOpencodePort } from "./port-detect" const children: import("child_process").ChildProcess[] = [] +let isShuttingDown = false const logDir = join(homedir(), ".crosscode") const configFile = join(logDir, "config.json") const MAX_LOG_SIZE = 1024 * 1024 @@ -118,6 +119,13 @@ type Config = { ngrokToken?: string port?: number tunnelWsUrl?: string + sessionToken?: string + projectId?: string + cloudflaredTunnel?: { + name: string + credentialsPath: string + url: string + } auth?: { email?: string sessionToken?: string @@ -138,6 +146,81 @@ function saveConfig(config: Config) { writeFileSync(configFile, JSON.stringify(config, null, 2), { mode: 0o600 }) } +// Stable, persistent session identity so the QR/URL stays the same across +// CLI restarts and network blips. Without this the opencode password + QR +// token are randomized every run, making it impossible for a returning +// mobile client to reconnect. +function ensureSessionToken(config: Config): string { + if (!config.sessionToken) { + config.sessionToken = crypto.randomBytes(32).toString("hex") + saveConfig(config) + logCrosscode(`Session token generated (censored: ${censorToken(config.sessionToken)})`) + } else { + debug("session token reused from config") + } + return config.sessionToken +} + +function ensureProjectId(config: Config): string { + if (!config.projectId) { + config.projectId = crypto.randomBytes(4).toString("hex") + saveConfig(config) + logCrosscode(`Project ID generated: ${config.projectId}`) + } else { + debug("project ID reused from config", { projectId: config.projectId }) + } + return config.projectId +} + +const cloudflaredTunnelDir = join(logDir, "cloudflared") +const cfCertPath = join(homedir(), ".cloudflared", "cert.pem") + +// Use a persistent named cloudflared tunnel so the public URL is stable +// (.cfargotunnel.com) across restarts and reconnects, instead of +// the random ephemeral quick tunnel that rotates its URL on every (re)start. +// Returns null when cloudflared isn't logged in yet, falling back to the +// quick tunnel (which still works but changes URL on restart). +async function ensureCloudflaredNamedTunnel(config: Config): Promise<{ name: string; credentialsPath: string; url: string } | null> { + if (!existsSync(cfCertPath)) { + debug("cloudflared not logged in (no cert.pem); falling back to quick tunnel") + return null + } + if (config.cloudflaredTunnel && existsSync(config.cloudflaredTunnel.credentialsPath)) { + return config.cloudflaredTunnel + } + const projectId = ensureProjectId(config) + const name = `crosscode-${projectId}` + const credentialsPath = join(cloudflaredTunnelDir, `${name}.json`) + try { + if (!existsSync(cloudflaredTunnelDir)) mkdirSync(cloudflaredTunnelDir, { recursive: true, mode: 0o700 }) + execFileSync("cloudflared", ["tunnel", "create", "--credentials-file", credentialsPath, name], { stdio: "ignore" }) + } catch (e) { + debug("cloudflared tunnel create failed", { error: (e as Error).message }) + return null + } + let url = "" + try { + const creds = JSON.parse(readFileSync(credentialsPath, "utf-8")) + const id = creds.TunnelID || creds.id + if (id) url = `https://${id}.cfargotunnel.com` + } catch {} + config.cloudflaredTunnel = { name, credentialsPath, url } + saveConfig(config) + logCrosscode(`Cloudflared named tunnel created: ${name} (${url})`) + return config.cloudflaredTunnel +} + +function printTunnelQr(url: string, token: string, opencodePort: number, requestedPort: number) { + const payload = encodeQrPayload({ url, token, v: 1 }) + if (opencodePort !== requestedPort) { + console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) + } + console.log(chalk.cyanBright("\n Scan with CrossCode App:")) + qrcode.generate(payload, { small: true }) + console.log(chalk.grey(`URL: ${url}`)) + console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) +} + const WEB_URL = process.env.CROSSCODE_WEB_URL || "https://crosscode.site" const AUTH_API_URL = process.env.CROSSCODE_AUTH_URL || `${WEB_URL}/api/auth` @@ -431,9 +514,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} if (tunnelProvider === "tunnel") { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - const sessionToken = crypto.randomBytes(32).toString("hex") - logCrosscode(`Session token generated (censored: ${censorToken(sessionToken)})`) - debug("session token generated", { length: sessionToken.length }) + const sessionToken = ensureSessionToken(config) const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: process.cwd(), @@ -632,7 +713,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Connecting to tunnel server...") - const projectId = deriveProjectId() + const projectId = ensureProjectId(config) logCrosscode(`Project ID: ${projectId}`) debug("connecting to tunnel", { projectId, proxyPort }) @@ -653,24 +734,19 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} config.tunnelWsUrl, (url) => { clearTimeout(tunnelTimeout) + if (tunnelUrl) { + if (tunnelUrl !== url) { + tunnelUrl = url + logCrosscode("Tunnel URL changed: " + tunnelUrl) + console.log(chalk.yellow(`\n Tunnel URL changed: ${tunnelUrl}\n`)) + } + return + } tunnelUrl = url spinner.succeed(chalk.green("Tunnel ready")) logCrosscode("Tunnel ready: " + tunnelUrl) debug("tunnel connected", { tunnelUrl }) - - const payload = encodeQrPayload({ - url: tunnelUrl, - token: sessionToken, - v: 1 - }) - - if (opencodePort !== port) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) - } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) - qrcode.generate(payload, { small: true }) - console.log(chalk.grey(`URL: ${tunnelUrl}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) }, (err) => { clearTimeout(tunnelTimeout) @@ -701,9 +777,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - const sessionToken = crypto.randomBytes(32).toString("hex") - logCrosscode(`Session token generated (censored: ${censorToken(sessionToken)})`) - debug("session token generated", { length: sessionToken.length }) + const sessionToken = ensureSessionToken(config) const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: process.cwd(), @@ -765,20 +839,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} spinner.succeed(chalk.green("Tunnel ready")) logCrosscode("ngrok tunnel ready: " + tunnelUrl) debug("ngrok tunnel ready", { tunnelUrl }) - - const payload = encodeQrPayload({ - url: tunnelUrl, - token: sessionToken, - v: 1 - }) - - if (opencodePort !== port) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) - } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) - qrcode.generate(payload, { small: true }) - console.log(chalk.grey(`URL: ${tunnelUrl}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) + printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) } } catch (e) { debug("ngrok API parse error", { error: e instanceof Error ? e.message : String(e) }) @@ -797,9 +858,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} } else if (tunnelProvider === "cloudflared" || tunnelFailed) { const spinner = ora(chalk.blue("Starting ", chalk.italic("opencode serve"))).start() - const sessionToken = crypto.randomBytes(32).toString("hex") - logCrosscode(`Session token generated (censored: ${censorToken(sessionToken)})`) - debug("session token generated", { length: sessionToken.length }) + const sessionToken = ensureSessionToken(config) const opencode = spawnCmd("opencode", ["serve", "--print-logs", "--log-level", "DEBUG", "--port", String(port), "--hostname", "127.0.0.1"], { cwd: process.cwd(), @@ -972,64 +1031,81 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} }) }) - proxy.listen(proxyPort, "127.0.0.1", () => { + proxy.listen(proxyPort, "127.0.0.1", async () => { logCrosscode(`SSE proxy started on port ${proxyPort}`) debug("proxy listening", { port: proxyPort, targetPort: detectedPort }) spinner.text = chalk.green.italic("opencode serve running") + chalk.yellow.italic(" • Waiting for Cloudflare tunnel...") - const cf = spawnCmd("cloudflared", [ - "tunnel", - "--no-autoupdate", - "--config", "/dev/null", - "--url", `http://127.0.0.1:${proxyPort}` - ], { - stdio: ["ignore", "pipe", "pipe"] - }) - - children.push(cf) + const namedTunnel = await ensureCloudflaredNamedTunnel(config) + if (!namedTunnel) { + logCrosscode("Using ephemeral quick tunnel (URL changes on restart). Run `cloudflared tunnel login` once for a stable, persistent URL.") + } - cf.on("spawn", () => { - logCrosscode("cloudflared started (PID: " + cf.pid + ")") - debug("cloudflared spawned", { pid: cf.pid }) - }) - cf.on("error", (err) => { - logCrosscode("cloudflared error: " + err.message) - debug("cloudflared error", { error: err.message }) - }) + function setTunnelUrl(url: string) { + if (tunnelUrl) return + tunnelUrl = url + spinner.succeed(chalk.green("Tunnel ready")) + logCrosscode("Cloudflare tunnel ready: " + tunnelUrl) + debug("cloudflare tunnel ready", { tunnelUrl }) + printTunnelQr(tunnelUrl, sessionToken, opencodePort, port) + } - cf.stdout?.on("data", d => cloudflaredLogStream.write(d)) + function startCloudflared(): import("child_process").ChildProcess { + let cfArgs: string[] + if (namedTunnel) { + const cfgPath = join(cloudflaredTunnelDir, `${namedTunnel.name}.yml`) + writeFileSync(cfgPath, `url: http://127.0.0.1:${proxyPort}\ntunnel: ${namedTunnel.name}\ncredentials-file: ${namedTunnel.credentialsPath}\n`, { mode: 0o600 }) + cfArgs = ["tunnel", "--no-autoupdate", "--config", cfgPath, "run"] + } else { + cfArgs = ["tunnel", "--no-autoupdate", "--config", "/dev/null", "--url", `http://127.0.0.1:${proxyPort}`] + } - cf.stderr?.on("data", (data: Buffer) => { - const text = data.toString() + const cf = spawnCmd("cloudflared", cfArgs, { stdio: ["ignore", "pipe", "pipe"] }) + children.push(cf) - const m = text.match(/https:\/\/[a-zA-Z0-9.-]+\.trycloudflare\.com/) + cf.on("spawn", () => { + logCrosscode("cloudflared started (PID: " + cf.pid + ")") + debug("cloudflared spawned", { pid: cf.pid }) + }) + cf.on("error", (err) => { + logCrosscode("cloudflared error: " + err.message) + debug("cloudflared error", { error: err.message }) + }) - if (m && !tunnelUrl) { - tunnelUrl = m[0] - spinner.succeed(chalk.green("Tunnel ready")) - logCrosscode("Cloudflare tunnel ready: " + tunnelUrl) - debug("cloudflare tunnel ready", { tunnelUrl }) + cf.stdout?.on("data", d => cloudflaredLogStream.write(d)) - const payload = encodeQrPayload({ - url: tunnelUrl, - token: sessionToken, - v: 1 - }) + cf.stderr?.on("data", (data: Buffer) => { + const text = data.toString() + const m = text.match(/https:\/\/[a-zA-Z0-9.-]+\.(trycloudflare|cfargotunnel)\.com/) + if (m && !tunnelUrl) setTunnelUrl(m[0]) + cloudflaredLogStream.write(data) + }) - if (opencodePort !== port) { - console.log(chalk.yellow(`opencode running on port ${opencodePort}`)) + cf.on("exit", (code) => { + logCrosscode("cloudflared exited (code: " + code + ")") + if (isShuttingDown) return + if (namedTunnel) { + logCrosscode("Restarting cloudflared (named tunnel keeps stable URL)") + console.log(chalk.yellow("\n Cloudflare tunnel dropped — reconnecting (URL unchanged)...\n")) + startCloudflared() + } else { + console.log(chalk.red("\n Cloudflare tunnel exited. The session URL may have changed — restart crosscode to reconnect.\n")) } - console.log(chalk.cyanBright("\n Scan with CrossCode App:")) + }) - qrcode.generate(payload, { small: true }) + return cf + } - console.log(chalk.grey(`URL: ${tunnelUrl}`)) - console.log(chalk.dim.bold("[Press 'l' for logs • 'h' for help • Ctrl+C to exit]")) - } + const cf = startCloudflared() - cloudflaredLogStream.write(data) - }) + if (namedTunnel?.url && !tunnelUrl) { + const urlFallbackTimer = setTimeout(() => { + if (!tunnelUrl) setTunnelUrl(namedTunnel.url) + }, 8000) + cf.on("exit", () => clearTimeout(urlFallbackTimer)) + } }) + }) } @@ -1065,6 +1141,7 @@ ${chalk.dim("Documentation: https://github.com/snhsish/crosscode")} } const shutdown = (source?: string) => { + isShuttingDown = true console.log(chalk.yellow("\nShutting down...")) logCrosscode(`Shutting down... (source: ${source || "unknown"})`) debug("shutdown initiated", { source: source || "unknown" }) From 6545a82211cb0e7450b684e97dc0f3b4ddc9d4e9 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Thu, 27 Aug 2026 14:20:00 +0530 Subject: [PATCH 6/7] chore: version packages --- .changeset/stable-session-reconnect.md | 10 ---------- packages/crosscode/CHANGELOG.md | 13 +++++++++++++ packages/crosscode/package.json | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) delete mode 100644 .changeset/stable-session-reconnect.md diff --git a/.changeset/stable-session-reconnect.md b/.changeset/stable-session-reconnect.md deleted file mode 100644 index 3070854..0000000 --- a/.changeset/stable-session-reconnect.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"crosscode": patch ---- - -Stabilize the CLI session identity and tunnel URLs so a returning mobile client (e.g. after the PC sleeps or the network drops) can always reconnect instead of being stranded by a freshly generated QR/URL. - -- Persist `sessionToken` (opencode password + QR token) and `projectId` in `~/.crosscode/config.json` so they stay constant across CLI restarts. This makes the managed tunnel URL (`connect.crosscode.site/t/`) and the QR token stable. -- For free, unauthenticated users, use a persistent **named cloudflared tunnel** (`crosscode-`) which keeps a fixed `*.cfargotunnel.com` hostname across restarts and reconnects. Falls back to the ephemeral quick tunnel (URL still changes on restart) when `cloudflared tunnel login` hasn't been run. -- Print the QR code exactly once per identity; tunnel reconnects no longer regenerate it. -- Auto-restart a dropped named cloudflared tunnel without changing its URL. diff --git a/packages/crosscode/CHANGELOG.md b/packages/crosscode/CHANGELOG.md index 85c74af..c0011ae 100644 --- a/packages/crosscode/CHANGELOG.md +++ b/packages/crosscode/CHANGELOG.md @@ -1,5 +1,18 @@ # crosscode +## 0.6.5 + +### Patch Changes + +- 8b574ee: Stabilize the CLI session identity and tunnel URLs so a returning mobile client (e.g. after the PC sleeps or the network drops) can always reconnect instead of being stranded by a freshly generated QR/URL. + + - Persist `sessionToken` (opencode password + QR token) and `projectId` in `~/.crosscode/config.json` so they stay constant across CLI restarts. This makes the managed tunnel URL (`connect.crosscode.site/t/`) and the QR token stable. + - For free, unauthenticated users, use a persistent **named cloudflared tunnel** (`crosscode-`) which keeps a fixed `*.cfargotunnel.com` hostname across restarts and reconnects. Falls back to the ephemeral quick tunnel (URL still changes on restart) when `cloudflared tunnel login` hasn't been run. + - Print the QR code exactly once per identity; tunnel reconnects no longer regenerate it. + - Auto-restart a dropped named cloudflared tunnel without changing its URL. + +- CLI: edge cases for Windows + ## 0.6.4 ### Patch Changes diff --git a/packages/crosscode/package.json b/packages/crosscode/package.json index 052b95a..548dca8 100644 --- a/packages/crosscode/package.json +++ b/packages/crosscode/package.json @@ -8,7 +8,7 @@ "bin": { "crosscode": "./dist/cli.js" }, - "version": "0.6.4", + "version": "0.6.5", "description": "An OpenCode Remote Client CLI", "main": "./dist/index.js", "scripts": { From 99aa1f97f73fac6056d6f23893456b145361d959 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Thu, 27 Aug 2026 14:46:32 +0530 Subject: [PATCH 7/7] feat(mobile): show streaming indicator on session list Streaming chats now display pulsing Working badge on sessions screen. Adds global SSE subscription for session.status to keep streaming state synced across list and detail screens. --- apps/mobile/app/sessions.tsx | 27 +++++ apps/mobile/components/hooks/event-stream.ts | 113 +++++++++++++++++++ 2 files changed, 140 insertions(+) diff --git a/apps/mobile/app/sessions.tsx b/apps/mobile/app/sessions.tsx index f07294d..2d5095d 100644 --- a/apps/mobile/app/sessions.tsx +++ b/apps/mobile/app/sessions.tsx @@ -13,6 +13,9 @@ import { useSessions, Session } from "@/store/sessions.store" import { getSessionsByProjectDir, deleteSession, createSession } from "@/lib/sessions" import { THEME } from "@/lib/theme" import { cn } from "@/lib/utils" +import { useChatStore } from "@/store/chat.store" +import { useGlobalSessionStatus } from "@/components/hooks/event-stream" +import Animated, { useSharedValue, useAnimatedStyle, withRepeat, withTiming, Easing } from "react-native-reanimated" import AlertTriangle from "lucide-react-native/dist/esm/icons/triangle-alert" import ArrowLeft from "lucide-react-native/dist/esm/icons/arrow-left" import ArrowUpDown from "lucide-react-native/dist/esm/icons/arrow-up-down" @@ -67,6 +70,18 @@ function formatTime(ts: number, now: number) { return new Date(ts).toLocaleDateString() } +function StreamingPulse() { + const pulse = useSharedValue(1) + React.useEffect(() => { + pulse.value = withRepeat(withTiming(1.6, { duration: 900, easing: Easing.out(Easing.ease) }), -1, false) + }, []) + const style = useAnimatedStyle(() => ({ + transform: [{ scale: pulse.value }], + opacity: 1.2 - pulse.value * 0.6, + })) + return +} + const SessionItem = React.memo(function SessionItem({ session, isLast, @@ -81,6 +96,7 @@ const SessionItem = React.memo(function SessionItem({ now: number }) { const theme = useColorScheme().colorScheme ?? "light" + const isStreaming = useChatStore((s) => s.streamingBySession[session.id] ?? false) const [menuVisible, setMenuVisible] = React.useState(false) const pressTimer = React.useRef | null>(null) @@ -136,6 +152,15 @@ const SessionItem = React.memo(function SessionItem({ {session.agent && ` · ${session.agent}`} + {isStreaming && ( + + + + + + Working + + )} @@ -192,6 +217,8 @@ export default function SessionsScreen() { const connection = React.useMemo(() => connections.find((c) => c.id === current) ?? null, [connections, current]) const project = React.useMemo(() => projects.find((p) => p.connectionId === current) ?? null, [projects, current]) + useGlobalSessionStatus(connection?.url, connection?.token) + const [loading, setLoading] = React.useState(false) const [refreshing, setRefreshing] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState("") diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index e6272e3..0c18b0c 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -17,6 +17,119 @@ type MessagePart = Part & { messageID?: string } +export function useGlobalSessionStatus(url?: string, token?: string) { + const abortRef = useRef(null) + + useEffect(() => { + if (!url || !token) return + + const baseUrl = url.replace(/\/+$/, "") + const authHeader = getAuthHeader(token) + const eventUrl = `${baseUrl}/mobile-event` + const setStreaming = useChatStore.getState().setStreaming + + const abort = new AbortController() + abortRef.current = abort + let buffer = "" + let reconnectTimer: ReturnType | null = null + let reconnectAttempts = 0 + const maxReconnectAttempts = 10 + const reconnectDelays = [3000, 5000, 10000, 15000, 20000, 30000, 45000, 60000, 90000, 120000] + + function handleEvent(event: SSEEvent) { + const props = event.properties + switch (event.type) { + case "session.status": { + const sid = props.sessionID as string | undefined + if (!sid) return + const status = props.status as { type: string } | undefined + if (!status) return + if (status.type === "busy") setStreaming(sid, true) + else if (status.type === "idle") setStreaming(sid, false) + break + } + case "session.idle": { + const sid = props.sessionID as string | undefined + if (sid) setStreaming(sid, false) + break + } + } + } + + function scheduleReconnect() { + if (abort.signal.aborted) return + if (reconnectAttempts >= maxReconnectAttempts) return + reconnectAttempts++ + const delay = reconnectDelays[Math.min(reconnectAttempts - 1, reconnectDelays.length - 1)] + reconnectTimer = setTimeout(() => { + if (!abort.signal.aborted) connect() + }, delay) + } + + async function connect() { + try { + const response = await fetch(eventUrl, { + method: "POST", + headers: { + Authorization: authHeader, + Accept: "text/event-stream", + "Content-Type": "application/json", + }, + signal: abort.signal, + body: JSON.stringify({}), + }) + if (!response.ok) { + scheduleReconnect() + return + } + const reader = response.body?.getReader() + if (!reader) { + scheduleReconnect() + return + } + reconnectAttempts = 0 + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const events = buffer.split("\n\n") + buffer = events.pop() ?? "" + for (const eventBlock of events) { + if (!eventBlock.trim()) continue + let dataLines: string[] = [] + for (const line of eventBlock.split("\n")) { + const trimmed = line.trim() + if (trimmed.startsWith("data:")) dataLines.push(trimmed.slice(5).trim()) + } + if (dataLines.length > 0) { + const data = dataLines.join("\n") + try { + const parsed = JSON.parse(data) + let sseEvent: SSEEvent + if (parsed.payload && parsed.type === undefined) sseEvent = parsed.payload + else sseEvent = parsed + handleEvent(sseEvent) + } catch {} + } + } + } + scheduleReconnect() + } catch (err) { + if ((err as Error).name !== "AbortError") scheduleReconnect() + } + } + + connect() + + return () => { + if (reconnectTimer) clearTimeout(reconnectTimer) + abort.abort() + abortRef.current = null + } + }, [url, token]) +} + export function useEventStream(url?: string, sessionId?: string, token?: string, projectId?: string) { const abortRef = useRef(null)