diff --git a/README.md b/README.md index 636348f..157c0a5 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ npx crosscode npm install -g crosscode ``` +> **Note:** We recommend `npx crosscode@latest` over global install so you always pull the latest changes without needing to manually update. + ### Windows (WSL2) Run the same commands inside your WSL terminal. Node.js must be installed in WSL: @@ -129,7 +131,7 @@ sudo apt-get install -y nodejs npx crosscode ``` -Tip: `npx crosscode` always uses the latest version. If you installed it globally, run `npm update -g crosscode` to upgrade. +Tip: `npx crosscode@latest` always uses the latest version. If you installed it globally, run `npm update -g crosscode` to upgrade. --- diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index fa29dc6..62bea54 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -1,5 +1,5 @@ import * as React from "react" -import { FlatList, Image, Modal, Pressable, TextInput, View } from "react-native" +import { FlatList, Image, Modal, Pressable, RefreshControl, TextInput, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" import { Text } from "@/components/ui/text" @@ -256,6 +256,7 @@ export default function HomeScreen() { const theme = colorScheme ?? "light" const currentConnection = React.useMemo(() => connections.find((c) => c.id === current) ?? null, [connections, current]) + const [refreshing, setRefreshing] = React.useState(false) const [searchQuery, setSearchQuery] = React.useState("") const [filter, setFilter] = React.useState("all") const [sortBy, setSortBy] = React.useState("recent") @@ -264,9 +265,9 @@ export default function HomeScreen() { const lastHealthCheckRef = React.useRef(0) - const checkHealth = React.useCallback(async () => { + const checkHealth = React.useCallback(async (force = false) => { const now = Date.now() - if (now - lastHealthCheckRef.current < 30000) return + if (!force && now - lastHealthCheckRef.current < 30000) return lastHealthCheckRef.current = now await Promise.all(connections.map(async (conn) => { if (!conn.url || !conn.token) return @@ -284,6 +285,18 @@ export default function HomeScreen() { })) }, [connections.length, setConnectionHealth]) + const handleRefresh = React.useCallback(async () => { + setRefreshing(true) + await checkHealth(true) + if (currentConnection?.url && currentConnection?.token) { + const project = await getCurrentProject(currentConnection.url, currentConnection.token) + if (project) { + setProjectForConnection(currentConnection.id, project) + } + } + setRefreshing(false) + }, [checkHealth, currentConnection?.id, currentConnection?.url, currentConnection?.token, setProjectForConnection]) + useFocusEffect( React.useCallback(() => { checkHealth() @@ -489,6 +502,13 @@ export default function HomeScreen() { data={filteredConnections} keyExtractor={(c) => c.id} contentContainerStyle={filteredConnections.length === 0 ? undefined : { gap: 12, paddingBottom: 32 }} + refreshControl={ + + } ListEmptyComponent={ diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index d779c88..736f601 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -203,8 +203,9 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi return } try { - const perms = await getPendingPermissions(connection.url, connection.token) - const sessionPerms = perms.filter((p) => p.sessionID === sessionId) + const perms = await getPendingPermissions(connection.url, connection.token, sessionId) + console.log("[PERM-DEBUG] pollPermissions raw:", JSON.stringify(perms)) + const sessionPerms = perms.filter((p) => !p.sessionID || p.sessionID === sessionId) const current = usePermissions.getState().permissionsBySession[sessionId!] ?? EMPTY_PERMISSIONS if ( sessionPerms.length !== current.length || @@ -250,7 +251,7 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const handlePermissionReply = useCallback(async (requestId: string, reply: "once" | "always" | "reject", message?: string) => { if (!connection?.url || !connection?.token) return - const success = await replyToPermission(connection.url, connection.token, requestId, reply, message) + const success = await replyToPermission(connection.url, connection.token, requestId, reply, message, sessionId) if (success) { removePermission(sessionId!, requestId) } diff --git a/apps/mobile/app/sessions.tsx b/apps/mobile/app/sessions.tsx index 2d5095d..b154963 100644 --- a/apps/mobile/app/sessions.tsx +++ b/apps/mobile/app/sessions.tsx @@ -15,7 +15,6 @@ 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" @@ -70,18 +69,6 @@ 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, @@ -153,12 +140,8 @@ const SessionItem = React.memo(function SessionItem({ {isStreaming && ( - - - - - - Working + + )} diff --git a/apps/mobile/components/hooks/event-stream.ts b/apps/mobile/components/hooks/event-stream.ts index 3b5a277..cb7686b 100644 --- a/apps/mobile/components/hooks/event-stream.ts +++ b/apps/mobile/components/hooks/event-stream.ts @@ -1,11 +1,12 @@ import { useEffect, useRef } from "react" import { useChatStore } from "@/store/chat.store" import { useMessages, AssistantMessage, Message, Part } from "@/store/messages.store" -import { usePermissions, PermissionRequest } from "@/store/permissions.store" +import { usePermissions } from "@/store/permissions.store" import { useOpencodeStats } from "@/store/opencode-stats.store" import { useProjects } from "@/store/projects.store" import { getAuthHeader } from "@/lib/utils" import { notifyAgentStatus } from "@/lib/notifications" +import { normalizePermission } from "@/lib/permissions" type SSEEvent = { type: string @@ -406,13 +407,21 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, } case "permission.asked": { - const perm = props as unknown as PermissionRequest - if (!perm || perm.sessionID !== currentSessionId) return + const raw = props as unknown as Record + console.log("[PERM-DEBUG] permission.asked raw:", JSON.stringify(raw)) + const perm = normalizePermission(raw, currentSessionId) + console.log("[PERM-DEBUG] permission.asked normalized:", JSON.stringify(perm)) const current = usePermissions.getState().permissionsBySession[currentSessionId] ?? [] - if (!current.some((p) => p.id === perm.id)) { + const exists = + current.some((p) => p.id === perm.id) || + (!perm.id && + current.some( + (p) => p.permission === perm.permission && p.sessionID === currentSessionId + )) + if (!exists) { usePermissions.getState().setPermissions(currentSessionId, [...current, perm]) notifyAgentStatus({ - key: `${currentSessionId}:permission:${perm.id}`, + key: `${currentSessionId}:permission:${perm.id || perm.permission}`, kind: "permission", title: "Agent needs permission", message: perm.permission || "Review the pending permission request.", @@ -424,8 +433,10 @@ export function useEventStream(url?: string, sessionId?: string, token?: string, } case "permission.replied": { - const requestID = props.requestID as string | undefined - if (!requestID || props.sessionID !== currentSessionId) return + const requestID = + (props.requestID as string | undefined) ?? (props.id as string | undefined) + if (!requestID) return + if (props.sessionID && props.sessionID !== currentSessionId) return usePermissions.getState().removePermission(currentSessionId, requestID) break } diff --git a/apps/mobile/lib/permissions.ts b/apps/mobile/lib/permissions.ts index bf302cf..c34fc58 100644 --- a/apps/mobile/lib/permissions.ts +++ b/apps/mobile/lib/permissions.ts @@ -1,9 +1,85 @@ import { PermissionRequest } from "@/store/permissions.store" import { getAuthHeader } from "@/lib/utils" +type RawPermission = Record + +function asString(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) return value + return undefined +} + +function asStringArray(value: unknown): string[] { + if (Array.isArray(value)) { + return value + .map((v) => (typeof v === "string" ? v : typeof v === "object" && v !== null ? JSON.stringify(v) : undefined)) + .filter((v): v is string => typeof v === "string" && v.length > 0) + } + if (typeof value === "string" && value.length > 0) return [value] + return [] +} + +// OpenCode returns permission requests with varying shapes across versions: +// - id vs requestID +// - permission vs action +// - patterns vs resources +// - always vs save +// - tool { messageID, callID } vs source { type, messageID, id } +// Normalize everything into the mobile PermissionRequest shape so the UI can +// always render and reply to it. +export function normalizePermission(raw: RawPermission, fallbackSessionId?: string): PermissionRequest { + const id = asString(raw.id) ?? asString(raw.requestID) ?? asString(raw.permissionID) ?? "" + const sessionID = asString(raw.sessionID) ?? asString(raw.sessionId) ?? fallbackSessionId ?? "" + + const permission = + asString(raw.permission) ?? + asString(raw.action) ?? + asString(raw.type) ?? + "access" + + const patterns = asStringArray(raw.patterns).length + ? asStringArray(raw.patterns) + : asStringArray(raw.resources) + + const always = asStringArray(raw.always).length + ? asStringArray(raw.always) + : asStringArray(raw.save) + + const metadata = + raw.metadata && typeof raw.metadata === "object" + ? (raw.metadata as Record) + : typeof raw.permission === "object" && raw.permission !== null + ? (raw.permission as Record) + : {} + + let tool = raw.tool && typeof raw.tool === "object" ? (raw.tool as Record) : undefined + if (!tool && raw.source && typeof raw.source === "object") { + const source = raw.source as Record + tool = { + messageID: asString(source.messageID) ?? "", + callID: asString(source.id) ?? asString(source.callID) ?? "", + } + } + + const normalizedTool = + tool && typeof tool.messageID === "string" && typeof tool.callID === "string" + ? { messageID: tool.messageID, callID: tool.callID } + : undefined + + return { + id, + sessionID, + permission, + patterns, + metadata, + always, + tool: normalizedTool, + } +} + export const getPendingPermissions = async ( url: string, - token: string + token: string, + sessionId?: string ): Promise => { try { const res = await fetch(`${url}/permission`, { @@ -14,7 +90,10 @@ export const getPendingPermissions = async ( }) if (!res.ok) return [] const data = await res.json() - return Array.isArray(data) ? data : [] + const list = Array.isArray(data) ? data : Array.isArray((data as { data?: unknown }).data) ? ((data as { data: unknown[] }).data) : [] + return list + .filter((p): p is RawPermission => !!p && typeof p === "object") + .map((p) => normalizePermission(p, sessionId)) } catch { return [] } @@ -25,21 +104,35 @@ export const replyToPermission = async ( token: string, requestId: string, reply: "once" | "always" | "reject", - message?: string + message?: string, + sessionId?: string ): Promise => { - try { - const body: Record = { reply } - if (message) body.message = message - const res = await fetch(`${url}/permission/${requestId}/reply`, { - method: "POST", - headers: { - Authorization: getAuthHeader(token), - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }) - return res.ok - } catch { - return false + const tryReply = async (path: string): Promise => { + try { + const body: Record = { reply } + if (message) body.message = message + const res = await fetch(`${url}${path}`, { + method: "POST", + headers: { + Authorization: getAuthHeader(token), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }) + return res.ok + } catch { + return false + } + } + + // OpenCode exposes the reply endpoint in a couple of shapes across versions. + const candidates = [ + `/permission/${requestId}/reply`, + sessionId ? `/session/${sessionId}/permission/${requestId}/reply` : null, + ].filter((c): c is string => !!c) + + for (const path of candidates) { + if (await tryReply(path)) return true } + return false } diff --git a/packages/crosscode/README.md b/packages/crosscode/README.md index 829e011..06047ce 100644 --- a/packages/crosscode/README.md +++ b/packages/crosscode/README.md @@ -14,6 +14,12 @@ or, if using `pnpm`: pnpm dlx crosscode ``` +You can also install globally (though `npx crosscode@latest` is recommended so you always get the latest version): + +```bash +npm i -g crosscode +``` + Scan the QR code with the [CrossCode mobile app](https://crosscode.site) to connect. > Requires `opencode` installed. Tunnel provider (`cloudflared` or `ngrok`) required based on tier.