From 89be6ab1d36ac9edc4ac2399d869d538e9c3cd79 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Thu, 1 Oct 2026 22:00:08 +0530 Subject: [PATCH 1/2] chore(ci): pin appleboy/ssh-action to v1.2.5, drop trailing exit 0 The deploy job flaked with 'remote command exited without exit status or exit signal' after all remote commands had succeeded (run 36891138426). Pin the floating @v1 tag and remove the explicit trailing 'exit 0', which races drone-ssh session teardown. --- .github/workflows/deploy-tunnel.yml | 3 +-- .github/workflows/deploy.yml | 3 +-- .github/workflows/preview-cleanup.yml | 2 +- .github/workflows/preview-deploy.yml | 4 ++-- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/deploy-tunnel.yml b/.github/workflows/deploy-tunnel.yml index 210147f..231c734 100644 --- a/.github/workflows/deploy-tunnel.yml +++ b/.github/workflows/deploy-tunnel.yml @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Deploy to server - uses: appleboy/ssh-action@v1 + uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} @@ -65,4 +65,3 @@ jobs: --env-file /home/deploy/crosscode/packages/tunnel-server/.env \ ghcr.io/${{ github.repository }}/tunnel-server:latest docker image prune -f || true - exit 0 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 108469a..87ce09a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -56,7 +56,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Deploy to server - uses: appleboy/ssh-action@v1 + uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} @@ -72,4 +72,3 @@ jobs: --env-file /home/deploy/crosscode/apps/web/.env \ ghcr.io/${{ github.repository }}/web:latest docker image prune -f || true - exit 0 diff --git a/.github/workflows/preview-cleanup.yml b/.github/workflows/preview-cleanup.yml index a3c6f15..e1f4034 100644 --- a/.github/workflows/preview-cleanup.yml +++ b/.github/workflows/preview-cleanup.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Cleanup preview containers - uses: appleboy/ssh-action@v1 + uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} diff --git a/.github/workflows/preview-deploy.yml b/.github/workflows/preview-deploy.yml index 38c3be7..ee8609b 100644 --- a/.github/workflows/preview-deploy.yml +++ b/.github/workflows/preview-deploy.yml @@ -73,7 +73,7 @@ jobs: NEXT_PUBLIC_TURNSTILE_SITE_KEY=${{ vars.NEXT_PUBLIC_TURNSTILE_SITE_KEY }} - name: Deploy web preview - uses: appleboy/ssh-action@v1 + uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} @@ -118,7 +118,7 @@ jobs: cache-to: type=gha,mode=max - name: Deploy tunnel preview - uses: appleboy/ssh-action@v1 + uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} From d88d24aab8ce9b7f2ea4803f27d302e2bc04b5c1 Mon Sep 17 00:00:00 2001 From: snehasishcodes Date: Fri, 16 Oct 2026 16:38:09 +0530 Subject: [PATCH 2/2] feat(mobile): multi-connection list with Connected/Unreachable status Remove Active/Available selection model; every connection is usable at any time via param-based routing with project.connectionId resolution. Hide add-connection entry points at tunnel limit instead of marking connections unusable. Widgets show aggregate stats across all connections plus a per-connection widget with a config screen. --- apps/mobile/app.config.js | 14 +- apps/mobile/app/(tabs)/index.tsx | 193 ++++++++--------- apps/mobile/app/(tabs)/user.tsx | 19 +- apps/mobile/app/connect.tsx | 4 +- .../project/[projectId]/[sessionId]/files.tsx | 6 +- .../[projectId]/[sessionId]/git-graph.tsx | 9 +- .../project/[projectId]/[sessionId]/index.tsx | 2 +- .../[projectId]/[sessionId]/models.tsx | 6 +- .../project/[projectId]/[sessionId]/tasks.tsx | 6 +- .../app/project/[projectId]/browser.tsx | 6 +- .../mobile/app/project/[projectId]/viewer.tsx | 6 +- apps/mobile/app/sessions.tsx | 8 +- apps/mobile/app/widget-config.tsx | 147 +++++++++++++ apps/mobile/components/message-item.tsx | 6 +- apps/mobile/components/session-header.tsx | 8 +- apps/mobile/lib/entitlement.ts | 8 +- apps/mobile/lib/project-connection.ts | 21 ++ apps/mobile/lib/widget-data.ts | 201 +++++++++++++++--- apps/mobile/lib/widget-task.tsx | 89 ++++++-- apps/mobile/store/connection.store.ts | 58 +++-- apps/mobile/widgets/ConnectionWidget.tsx | 120 +++++++++++ apps/mobile/widgets/shared.tsx | 47 +++- 22 files changed, 737 insertions(+), 247 deletions(-) create mode 100644 apps/mobile/app/widget-config.tsx create mode 100644 apps/mobile/lib/project-connection.ts create mode 100644 apps/mobile/widgets/ConnectionWidget.tsx diff --git a/apps/mobile/app.config.js b/apps/mobile/app.config.js index ee5aac0..7b666b9 100644 --- a/apps/mobile/app.config.js +++ b/apps/mobile/app.config.js @@ -17,7 +17,7 @@ module.exports = ({ config }) => { { name: "SmallWidget", label: "CrossCode Status", - description: "Connection status and active session", + description: "All connections status and latest session", minWidth: "110dp", minHeight: "40dp", targetCellWidth: 2, @@ -27,7 +27,7 @@ module.exports = ({ config }) => { { name: "MediumWidget", label: "CrossCode Session", - description: "Session status, today's usage and 7-day chart", + description: "All connections usage and 7-day chart", minWidth: "250dp", minHeight: "110dp", targetCellWidth: 4, @@ -44,6 +44,16 @@ module.exports = ({ config }) => { targetCellHeight: 3, resizeMode: "horizontal|vertical", }, + { + name: "ConnectionWidget", + label: "CrossCode Connection", + description: "Pick one connection to watch", + minWidth: "250dp", + minHeight: "110dp", + targetCellWidth: 4, + targetCellHeight: 2, + resizeMode: "horizontal|vertical", + }, ], }, ], diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx index 49d7d1d..6c5aff6 100644 --- a/apps/mobile/app/(tabs)/index.tsx +++ b/apps/mobile/app/(tabs)/index.tsx @@ -8,7 +8,6 @@ import { Dialog, DialogHeader, DialogFooter } from "@/components/ui/dialog" import { useFocusEffect, useRouter } from "expo-router" import { useConnections } from "@/store/connection.store" import { useProjects } from "@/store/projects.store" -import { useAllowsMultipleConnections } from "@/lib/entitlement" import { useAuth } from "@/store/auth.store" import { usePaywall } from "@/store/paywall.store" import { isAtTunnelLimit, requestPaywall } from "@/lib/paywall" @@ -34,13 +33,20 @@ import { THEME } from "@/lib/theme" import { useColorScheme } from "nativewind" import { Input } from "@/components/ui/input" -type FilterType = "all" | "active" | "inactive" +type FilterType = "all" | "connected" | "unreachable" type SortType = "name" | "recent" | "status" +type ConnectionStatus = "connected" | "checking" | "unreachable" + +function getConnectionStatus(healthy?: boolean | null): ConnectionStatus { + if (healthy === false) return "unreachable" + if (healthy == null) return "checking" + return "connected" +} const FILTER_OPTIONS: { key: FilterType; label: string }[] = [ { key: "all", label: "All" }, - { key: "active", label: "Active" }, - { key: "inactive", label: "Inactive" }, + { key: "connected", label: "Connected" }, + { key: "unreachable", label: "Unreachable" }, ] const SORT_OPTIONS: { key: SortType; label: string }[] = [ @@ -51,17 +57,13 @@ const SORT_OPTIONS: { key: SortType; label: string }[] = [ const ConnectionItem = React.memo(function ConnectionItem({ connection, - isActive, project, - allowMultiple, onNavigate, onRename, onDelete, }: { connection: { id: string; name: string; url: string; healthy?: boolean | null } - isActive: boolean project?: { directory: string } - allowMultiple: boolean onNavigate: (id: string) => void onRename: (id: string) => void onDelete: (id: string) => void @@ -69,8 +71,9 @@ const ConnectionItem = React.memo(function ConnectionItem({ const theme = useColorScheme().colorScheme ?? "light" const [menuVisible, setMenuVisible] = React.useState(false) const pressTimer = React.useRef | null>(null) - const isUnreachable = connection.healthy === false - const isSelectable = allowMultiple && !isActive + const status = getConnectionStatus(connection.healthy) + const isUnreachable = status === "unreachable" + const isChecking = status === "checking" React.useEffect(() => { return () => { @@ -94,10 +97,10 @@ const ConnectionItem = React.memo(function ConnectionItem({ }, []) const handlePress = React.useCallback(() => { - if (!menuVisible && (!isUnreachable || isSelectable)) { + if (!menuVisible) { onNavigate(connection.id) } - }, [menuVisible, isUnreachable, isSelectable, onNavigate, connection.id]) + }, [menuVisible, onNavigate, connection.id]) const handleRenamePress = React.useCallback(() => { setMenuVisible(false) @@ -114,35 +117,29 @@ const ConnectionItem = React.memo(function ConnectionItem({ - {isUnreachable && !isSelectable ? ( + {isUnreachable ? ( - ) : isActive ? ( - - ) : isSelectable ? ( - + ) : isChecking ? ( + ) : ( - + )} @@ -156,19 +153,19 @@ const ConnectionItem = React.memo(function ConnectionItem({ )} - {isUnreachable && !isSelectable ? ( + {isUnreachable ? ( Unreachable - ) : isActive ? ( - - Active + ) : isChecking ? ( + + Checking… - ) : isSelectable ? ( - - Available + ) : ( + + Connected - ) : null} + )} @@ -178,15 +175,13 @@ const ConnectionItem = React.memo(function ConnectionItem({ - - {isUnreachable && !isSelectable ? ( + + {isUnreachable ? ( - ) : isActive ? ( - - ) : isSelectable ? ( - + ) : isChecking ? ( + ) : ( - + )} @@ -201,19 +196,19 @@ const ConnectionItem = React.memo(function ConnectionItem({ {formatDirectory(project.directory)} )} - {isUnreachable && !isSelectable ? ( + {isUnreachable ? ( Unreachable - ) : isActive ? ( - - Active + ) : isChecking ? ( + + Checking… - ) : isSelectable ? ( - - Available + ) : ( + + Connected - ) : null} + )} @@ -250,12 +245,11 @@ export default function HomeScreen() { const router = useRouter() const { colorScheme } = useColorScheme() const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) const setCurrent = useConnections((s) => s.setCurrent) const setConnectionHealth = useConnections((s) => s.setConnectionHealth) - const allowMultiple = useAllowsMultipleConnections() const tier = useAuth((s) => s.user?.tier ?? "free") const atLimit = isAtTunnelLimit(tier, connections.length) + const canAddMore = !atLimit const handleAddConnection = React.useCallback(() => { if (atLimit) { @@ -270,7 +264,6 @@ export default function HomeScreen() { const updateConnection = useConnections((s) => s.updateConnection) 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("") @@ -299,17 +292,24 @@ export default function HomeScreen() { })) }, [connections.length, setConnectionHealth]) + const refreshProjects = React.useCallback(async (list: typeof connections) => { + await Promise.all(list.map(async (conn) => { + if (!conn.url || !conn.token) return + try { + const project = await getCurrentProject(conn.url, conn.token) + if (project) { + setProjectForConnection(conn.id, project) + } + } catch {} + })) + }, [setProjectForConnection]) + 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) - } - } + await refreshProjects(useConnections.getState().connections) setRefreshing(false) - }, [checkHealth, currentConnection?.id, currentConnection?.url, currentConnection?.token, setProjectForConnection]) + }, [checkHealth, refreshProjects]) useFocusEffect( React.useCallback(() => { @@ -318,14 +318,9 @@ export default function HomeScreen() { ) React.useEffect(() => { - if (!currentConnection?.url || !currentConnection?.token) return - - getCurrentProject(currentConnection.url, currentConnection.token).then((project) => { - if (project) { - setProjectForConnection(currentConnection.id, project) - } - }) - }, [currentConnection?.id, currentConnection?.url, currentConnection?.token, setProjectForConnection]) + if (connections.length === 0) return + refreshProjects(connections) + }, [connections.length, refreshProjects]) const filteredConnections = React.useMemo(() => { let result = [...connections] @@ -337,10 +332,10 @@ export default function HomeScreen() { ) } - if (filter === "active") { - result = result.filter((c) => c.id === current) - } else if (filter === "inactive") { - result = result.filter((c) => c.id !== current) + if (filter === "connected") { + result = result.filter((c) => c.healthy !== false) + } else if (filter === "unreachable") { + result = result.filter((c) => c.healthy === false) } if (sortBy === "name") { @@ -348,15 +343,13 @@ export default function HomeScreen() { } else if (sortBy === "recent") { result.sort((a, b) => b.added - a.added) } else if (sortBy === "status") { - result.sort((a, b) => { - if (a.id === current && b.id !== current) return -1 - if (a.id !== current && b.id === current) return 1 - return 0 - }) + const rank = (c: { healthy?: boolean | null }) => + c.healthy === false ? 2 : c.healthy == null ? 1 : 0 + result.sort((a, b) => rank(a) - rank(b)) } return result - }, [connections, searchQuery, filter, sortBy, current]) + }, [connections, searchQuery, filter, sortBy]) const projectByConnectionId = React.useMemo(() => { const map = new Map() @@ -393,8 +386,10 @@ export default function HomeScreen() { }, []) const handleNavigate = React.useCallback((id: string) => { + // Keep `current` as a legacy fallback for not-yet-migrated screens, + // but navigation itself is param-based so any connection is usable. setCurrent(id) - router.push("/sessions") + router.push({ pathname: "/sessions", params: { connectionId: id } }) }, [router, setCurrent]) const handleDeleteConfirm = React.useCallback(() => { @@ -559,25 +554,33 @@ export default function HomeScreen() { : "Add a remote OpenCode connection to get started"} - + {canAddMore ? ( + + ) : ( + + )} } renderItem={({ item: c }) => { - const isActive = c.id === current const project = projectByConnectionId.get(c.id) return ( - {filteredConnections.length > 0 && ( + {filteredConnections.length > 0 && canAddMore && ( s.logout) const isLoggedIn = useAuth((s) => s.isLoggedIn) - const clearLastRemoteUrlOnClose = useSettings((s) => s.clearLastRemoteUrlOnClose) - const setClearLastRemoteUrlOnClose = useSettings((s) => s.setClearLastRemoteUrlOnClose) const notifications = useSettings((s) => s.notifications) const setNotifications = useSettings((s) => s.setNotifications) const emailForUpdates = useSettings((s) => s.emailForUpdates) @@ -75,20 +72,6 @@ export default function UserPage() { const setAllowTerminal = useSettings((s) => s.setAllowTerminal) const resetOnboarding = useSettings((s) => s.resetOnboarding) - const setCurrent = useConnections((s) => s.setCurrent) - - React.useEffect(() => { - if (!clearLastRemoteUrlOnClose) return - - const subscription = AppState.addEventListener("change", (nextAppState) => { - if (nextAppState === "background" || nextAppState === "inactive") { - setCurrent("" as never) - } - }) - - return () => subscription.remove() - }, [clearLastRemoteUrlOnClose, setCurrent]) - React.useEffect(() => { if (!serverUrl || !sessionToken) return diff --git a/apps/mobile/app/connect.tsx b/apps/mobile/app/connect.tsx index 18c67e1..eb6c0be 100644 --- a/apps/mobile/app/connect.tsx +++ b/apps/mobile/app/connect.tsx @@ -88,7 +88,7 @@ export default function Connect() { void requestPaywall("connection_limit") return } - addConnection({ + const id = addConnection({ url, name, token, @@ -96,7 +96,7 @@ export default function Connect() { }) cacheServerVersion(url, serverVersion ?? 1) consumePendingConnection() - router.replace("/") + router.replace({ pathname: "/sessions", params: { connectionId: id } }) } useEffect(() => { diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx index 2d600b9..2d429c4 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/files.tsx @@ -14,7 +14,7 @@ import { useDiffStore } from "@/store/diff.store" import { fetchSessionDiffs, FileDiff } from "@/lib/diff" import { getMessages } from "@/lib/messages" import { Message, Part } from "@/store/messages.store" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" function toFileDiff(value: unknown): FileDiff | null { if (!value || typeof value !== "object") return null @@ -38,9 +38,7 @@ export default function FilesPage() { const theme = (colorScheme ?? "light") as "light" | "dark" const { projectId, sessionId } = useLocalSearchParams<{ projectId: string; sessionId: string }>() const setDiff = useDiffStore((s) => s.setDiff) - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { connection } = useProjectConnection(projectId) const [files, setFiles] = useState([]) const [loading, setLoading] = useState(true) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/git-graph.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/git-graph.tsx index 1235a67..e7af026 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/git-graph.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/git-graph.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { FlatList, RefreshControl, View } from "react-native" import * as Clipboard from "expo-clipboard" -import { useRouter } from "expo-router" +import { useLocalSearchParams, useRouter } from "expo-router" import { useSafeAreaInsets } from "react-native-safe-area-context" import { useColorScheme } from "nativewind" import ArrowLeftIcon from "lucide-react-native/dist/esm/icons/arrow-left" @@ -13,7 +13,7 @@ import { THEME } from "@/lib/theme" import { computeGitGraph } from "@/lib/git-graph" import { GitGraphRow, GIT_ROW_HEIGHT } from "@/components/git-graph-row" import { useGitStore } from "@/store/git.store" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { useSettings } from "@/store/settings.store" const LANE_WIDTH = 13 @@ -25,9 +25,8 @@ export default function GitGraphPage() { const theme = colorScheme ?? "light" const isDark = theme === "dark" - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { projectId } = useLocalSearchParams<{ projectId: string }>() + const { connection } = useProjectConnection(projectId) const commits = useGitStore((s) => s.commits) const status = useGitStore((s) => s.status) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx index 3b70269..1da57b8 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/index.tsx @@ -133,8 +133,8 @@ function SessionScreenInner({ projectId, sessionId }: { projectId: string; sessi const theme = (colorScheme ?? "light") as "light" | "dark" - const connection = useMemo(() => connections.find((c) => c.id === current) ?? null, [connections, current]) const project = useMemo(() => projects.find((p) => p.id === projectId) ?? null, [projects, projectId]) + const connection = useMemo(() => connections.find((c) => c.id === (project?.connectionId ?? current)) ?? null, [connections, current, project?.connectionId]) const session = useMemo(() => sessions.find((s) => s.id === sessionId) ?? null, [sessions, sessionId]) const isStreamingSelector = useMemo( diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx index ad6ebcf..bf06c91 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/models.tsx @@ -21,7 +21,7 @@ import { Icon } from "@/components/ui/icon" import { THEME } from "@/lib/theme" import { cn } from "@/lib/utils" import { useModels } from "@/store/models.store" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { useChatStore } from "@/store/chat.store" import { updateSessionModel } from "@/lib/models" @@ -61,9 +61,7 @@ export default function ModelsPage() { currentProviderId?: string agent?: string }>() - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { connection } = useProjectConnection(projectId) const models = useModels((s) => s.models) const providers = useModels((s) => s.providers) const fetchAll = useModels((s) => s.fetchAll) diff --git a/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx b/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx index 0d13ad7..00ae77a 100644 --- a/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx +++ b/apps/mobile/app/project/[projectId]/[sessionId]/tasks.tsx @@ -12,7 +12,7 @@ import { Button } from "@/components/ui/button" import { Text } from "@/components/ui/text" import { THEME } from "@/lib/theme" import { cn } from "@/lib/utils" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { fetchSessionTodos, TodoTask } from "@/lib/todo" const STATUS_CONFIG: Record = { @@ -34,9 +34,7 @@ export default function TasksPage() { const { colorScheme } = useColorScheme() const theme = (colorScheme ?? "light") as "light" | "dark" const { projectId, sessionId } = useLocalSearchParams<{ projectId: string; sessionId: string }>() - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { connection } = useProjectConnection(projectId) const [todos, setTodos] = useState([]) const [loading, setLoading] = useState(true) diff --git a/apps/mobile/app/project/[projectId]/browser.tsx b/apps/mobile/app/project/[projectId]/browser.tsx index 83bcf73..22b3ec9 100644 --- a/apps/mobile/app/project/[projectId]/browser.tsx +++ b/apps/mobile/app/project/[projectId]/browser.tsx @@ -12,7 +12,7 @@ import XIcon from "lucide-react-native/dist/esm/icons/x" import { Button } from "@/components/ui/button" import { Text } from "@/components/ui/text" import { THEME } from "@/lib/theme" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { useProjects } from "@/store/projects.store" import { buildBreadcrumbs, @@ -75,9 +75,7 @@ export default function BrowserPage() { const t = THEME[theme] const { projectId } = useLocalSearchParams<{ projectId: string }>() - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { connection } = useProjectConnection(projectId) const connected = !!(connection?.url && connection?.token) const project = useProjects((s) => s.projects.find((p) => p.id === projectId)) const rootLabel = useMemo(() => { diff --git a/apps/mobile/app/project/[projectId]/viewer.tsx b/apps/mobile/app/project/[projectId]/viewer.tsx index 86b1b13..e8e6333 100644 --- a/apps/mobile/app/project/[projectId]/viewer.tsx +++ b/apps/mobile/app/project/[projectId]/viewer.tsx @@ -11,7 +11,7 @@ import FileQuestionIcon from "lucide-react-native/dist/esm/icons/file-question" import { Button } from "@/components/ui/button" import { Text } from "@/components/ui/text" import { THEME } from "@/lib/theme" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { isBinaryPath, languageFromPath, readFileContent } from "@/lib/file-browser" import { CodeBlock } from "@/components/code-block" @@ -26,9 +26,7 @@ export default function ViewerPage() { const { projectId, path } = useLocalSearchParams<{ projectId: string; path?: string }>() const filePath = typeof path === "string" ? path : "" - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { connection } = useProjectConnection(projectId) const [content, setContent] = useState(null) const [truncated, setTruncated] = useState(false) diff --git a/apps/mobile/app/sessions.tsx b/apps/mobile/app/sessions.tsx index 3668dec..6b5ad43 100644 --- a/apps/mobile/app/sessions.tsx +++ b/apps/mobile/app/sessions.tsx @@ -1,7 +1,7 @@ import * as React from "react" import { ActivityIndicator, Modal, Pressable, RefreshControl, SectionList, TextInput, View } from "react-native" import { useSafeAreaInsets } from "react-native-safe-area-context" -import { useRouter } from "expo-router" +import { useLocalSearchParams, useRouter } from "expo-router" import { useColorScheme } from "nativewind" import { Text } from "@/components/ui/text" @@ -201,14 +201,16 @@ export default function SessionsScreen() { const { colorScheme } = useColorScheme() const connections = useConnections((s) => s.connections) const current = useConnections((s) => s.current) + const { connectionId: connectionIdParam } = useLocalSearchParams<{ connectionId?: string }>() const projects = useProjects((s) => s.projects) const sessions = useSessions((s) => s.sessions) const upsertSessions = useSessions((s) => s.upsertSessions) const theme = colorScheme ?? "light" - 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]) + const resolvedConnectionId = connectionIdParam ?? current + const connection = React.useMemo(() => connections.find((c) => c.id === resolvedConnectionId) ?? null, [connections, resolvedConnectionId]) + const project = React.useMemo(() => projects.find((p) => p.connectionId === resolvedConnectionId) ?? null, [projects, resolvedConnectionId]) useGlobalSessionStatus(connection?.url, connection?.token) diff --git a/apps/mobile/app/widget-config.tsx b/apps/mobile/app/widget-config.tsx new file mode 100644 index 0000000..934a693 --- /dev/null +++ b/apps/mobile/app/widget-config.tsx @@ -0,0 +1,147 @@ +import * as React from "react" +import { ActivityIndicator, Pressable, ScrollView, View } from "react-native" +import { useLocalSearchParams, useRouter } from "expo-router" +import { useSafeAreaInsets } from "react-native-safe-area-context" +import { useColorScheme } from "nativewind" +import ArrowLeft from "lucide-react-native/dist/esm/icons/arrow-left" +import Check from "lucide-react-native/dist/esm/icons/check" +import Wifi from "lucide-react-native/dist/esm/icons/wifi" +import WifiOff from "lucide-react-native/dist/esm/icons/wifi-off" + +import { Text } from "@/components/ui/text" +import { THEME } from "@/lib/theme" +import { cn } from "@/lib/utils" +import { useConnections } from "@/store/connection.store" +import { setWidgetConnectionId } from "@/lib/widget-data" +import { refreshConnectionWidgets } from "@/lib/widget-task" + +export default function WidgetConfigScreen() { + const insets = useSafeAreaInsets() + const router = useRouter() + const { colorScheme } = useColorScheme() + const theme = colorScheme ?? "light" + const { widgetName, widgetId } = useLocalSearchParams<{ + widgetName?: string + widgetId?: string + }>() + const connections = useConnections((s) => s.connections) + + const parsedWidgetId = Number(widgetId) + const canSave = + typeof widgetName === "string" && + widgetName.length > 0 && + Number.isFinite(parsedWidgetId) + + const [savingId, setSavingId] = React.useState(null) + + const handleSelect = React.useCallback( + async (connectionId: string) => { + if (!canSave || savingId) return + setSavingId(connectionId) + try { + await setWidgetConnectionId(widgetName as string, parsedWidgetId, connectionId) + await refreshConnectionWidgets().catch(() => undefined) + } finally { + setSavingId(null) + } + router.back() + }, + [canSave, parsedWidgetId, router, savingId, widgetName] + ) + + return ( + + + + router.back()} className="p-2 -ml-2"> + + + + Widget connection + + Choose which connection this widget shows + + + + + + {!canSave ? ( + + + Open this screen by tapping a connection widget on your home screen. + + + ) : connections.length === 0 ? ( + + + No connections yet. Add one first, then pick it here. + + + ) : ( + + {connections.map((c) => { + const unreachable = c.healthy === false + const checking = c.healthy == null + const saving = savingId === c.id + return ( + handleSelect(c.id)} + disabled={savingId !== null} + className={cn( + "flex-row items-center gap-3 rounded-xl p-4", + unreachable + ? "bg-muted/30 active:bg-muted/50" + : "bg-muted/50 border border-border active:bg-muted/70" + )} + style={unreachable ? { opacity: 0.6 } : undefined} + > + + {unreachable ? ( + + ) : ( + + )} + + + {c.name} + + {c.url.replace(/^https?:\/\//, "")} + + + {saving ? ( + + ) : unreachable ? ( + + Unreachable + + ) : checking ? ( + + Checking… + + ) : ( + + Connected + + )} + + + ) + })} + + )} + + ) +} diff --git a/apps/mobile/components/message-item.tsx b/apps/mobile/components/message-item.tsx index a370e83..becb484 100644 --- a/apps/mobile/components/message-item.tsx +++ b/apps/mobile/components/message-item.tsx @@ -24,7 +24,7 @@ import { AgentBadge } from "@/components/agent-badge" import { QuestionRequest } from "@/store/questions.store" import { PermissionRequest } from "@/store/permissions.store" import { revertMessage, forkSession } from "@/lib/sessions" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { useSessions } from "@/store/sessions.store" import { useModels } from "@/store/models.store" import { useSelectiveCopy } from "@/store/selective-copy.store" @@ -453,9 +453,7 @@ function MessageItemInner({ message, theme, projectId, sessionId, pendingQuestio const containerRef = useRef(null) const touchPosRef = useRef({ x: 0, y: 0 }) - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { connection } = useProjectConnection(projectId) const upsertSession = useSessions((s) => s.upsertSession) const hasError = message.role === "assistant" && "error" in message && message.error diff --git a/apps/mobile/components/session-header.tsx b/apps/mobile/components/session-header.tsx index 30998f2..7879ddf 100644 --- a/apps/mobile/components/session-header.tsx +++ b/apps/mobile/components/session-header.tsx @@ -13,7 +13,7 @@ import { Button } from "@/components/ui/button" import { Text } from "@/components/ui/text" import { THEME } from "@/lib/theme" import { shareSession } from "@/lib/sessions" -import { useConnections } from "@/store/connection.store" +import { useProjectConnection } from "@/lib/project-connection" import { useSessions } from "@/store/sessions.store" import { useSettings } from "@/store/settings.store" import { ShareSessionModal } from "@/components/share-session-modal" @@ -45,9 +45,7 @@ function SessionHeaderInner({ const [showShareModal, setShowShareModal] = useState(false) const [shareUrl, setShareUrl] = useState(null) const [shareLoading, setShareLoading] = useState(false) - const connections = useConnections((s) => s.connections) - const current = useConnections((s) => s.current) - const connection = connections.find((c) => c.id === current) ?? null + const { project, connection } = useProjectConnection(projectId) const upsertSession = useSessions((s) => s.upsertSession) const allowTerminal = useSettings((s) => s.allowTerminal) @@ -83,7 +81,7 @@ function SessionHeaderInner({ className="flex flex-row gap-2 items-center border-b border-accent pb-2 px-4" style={{ paddingTop: paddingTop + 10 }} > - diff --git a/apps/mobile/lib/entitlement.ts b/apps/mobile/lib/entitlement.ts index 6d1fb3b..fb5e444 100644 --- a/apps/mobile/lib/entitlement.ts +++ b/apps/mobile/lib/entitlement.ts @@ -1,13 +1,7 @@ -import { useAuth } from "@/store/auth.store" - const PAID_TIERS = ["starter", "builder"] export function isPaidTier(tier?: string | null) { return !!tier && PAID_TIERS.includes(tier) } -export function useAllowsMultipleConnections() { - const isLoggedIn = useAuth((s) => s.isLoggedIn) - const tier = useAuth((s) => s.user?.tier) - return isLoggedIn && isPaidTier(tier) -} + diff --git a/apps/mobile/lib/project-connection.ts b/apps/mobile/lib/project-connection.ts new file mode 100644 index 0000000..8a88cba --- /dev/null +++ b/apps/mobile/lib/project-connection.ts @@ -0,0 +1,21 @@ +import { useConnections } from "@/store/connection.store" +import { useProjects } from "@/store/projects.store" + +/** + * Resolve the connection that owns a project. + * + * Multi-connection: every project records `connectionId`, so screens under + * `/project/[projectId]/...` must use the owning connection — not a global + * "current"/selected connection. Falls back to the legacy `current` id for + * deep links / screens that don't know their project yet. + */ +export function useProjectConnection(projectId?: string | null) { + const connections = useConnections((s) => s.connections) + const current = useConnections((s) => s.current) + const project = useProjects((s) => + projectId ? (s.projects.find((p) => p.id === projectId) ?? null) : null + ) + const connection = + connections.find((c) => c.id === (project?.connectionId ?? current)) ?? null + return { project, connection } +} diff --git a/apps/mobile/lib/widget-data.ts b/apps/mobile/lib/widget-data.ts index 74e341c..14b23ed 100644 --- a/apps/mobile/lib/widget-data.ts +++ b/apps/mobile/lib/widget-data.ts @@ -6,6 +6,9 @@ const CONNECTIONS_KEY = "crosscode-connections" const SESSIONS_KEY = "crosscode-sessions" const STATS_KEY = "crosscode-opencode-stats" const QUESTIONS_KEY = "crosscode-questions" +const PROJECTS_KEY = "crosscode-projects" +/** widget instance (`"${widgetName}:${widgetId}"`) -> connection id chosen in the config screen. */ +const WIDGET_CONNECTIONS_KEY = "crosscode-widget-connections" type Persisted = { state: T } @@ -32,6 +35,13 @@ interface Session { summary?: SessionSummary } +interface StoredProject { + id: string + name: string + directory: string + connectionId: string +} + interface ProjectStats { projectId: string projectName: string @@ -91,8 +101,11 @@ export interface ProjectWidgetData { } export interface WidgetData { + /** True when at least one connection is reachable. */ connected: boolean - serverName: string + connectedCount: number + unreachableCount: number + totalCount: number hasSession: boolean sessionTitle: string sessionAgent: string @@ -108,38 +121,86 @@ export interface WidgetData { projects: ProjectWidgetData[] } -export async function buildWidgetData(): Promise { - const [connections, sessionsState, statsState, questions] = await Promise.all([ - readPersisted<{ connections: Connection[]; current: string | null }>(CONNECTIONS_KEY), - readPersisted<{ sessions: Session[] }>(SESSIONS_KEY), - readPersisted<{ projects: Record }>(STATS_KEY), - readPersisted<{ questionsBySession: Record }>(QUESTIONS_KEY), - ]) - const stats = statsState?.projects - const sessions = sessionsState?.sessions - - 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 +export interface ConnectionWidgetData extends WidgetData { + connectionId: string | null + connectionName: string + connectionHealthy: boolean | null +} - const pendingQuestions = questions - ? Object.values(questions.questionsBySession ?? {}).reduce( - (sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), - 0 - ) - : 0 +export function widgetInstanceKey(widgetName: string, widgetId: number): string { + return `${widgetName}:${widgetId}` +} - const days = last7Days() - const today = todayKey() - const projectsMap = stats ?? {} - const projects: ProjectWidgetData[] = Object.values(projectsMap) +async function readWidgetConnections(): Promise> { + try { + const raw = await AsyncStorage.getItem(WIDGET_CONNECTIONS_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + return parsed && typeof parsed === "object" ? parsed : {} + } catch { + return {} + } +} + +export async function getWidgetConnectionId( + widgetName: string, + widgetId: number +): Promise { + const map = await readWidgetConnections() + return map[widgetInstanceKey(widgetName, widgetId)] ?? null +} + +export async function setWidgetConnectionId( + widgetName: string, + widgetId: number, + connectionId: string | null +): Promise { + const map = await readWidgetConnections() + const key = widgetInstanceKey(widgetName, widgetId) + if (connectionId) { + map[key] = connectionId + } else { + delete map[key] + } + await AsyncStorage.setItem(WIDGET_CONNECTIONS_KEY, JSON.stringify(map)) +} + +interface LoadedState { + connections: Connection[] + sessions: Session[] + projectsMap: Record + storedProjects: StoredProject[] + pendingQuestions: number +} + +async function loadState(): Promise { + const [connectionsState, sessionsState, statsState, questions, projectsState] = + await Promise.all([ + readPersisted<{ connections: Connection[]; current: string | null }>(CONNECTIONS_KEY), + readPersisted<{ sessions: Session[] }>(SESSIONS_KEY), + readPersisted<{ projects: Record }>(STATS_KEY), + readPersisted<{ questionsBySession: Record }>(QUESTIONS_KEY), + readPersisted<{ projects: StoredProject[] }>(PROJECTS_KEY), + ]) + return { + connections: connectionsState?.connections ?? [], + sessions: sessionsState?.sessions ?? [], + projectsMap: statsState?.projects ?? {}, + storedProjects: projectsState?.projects ?? [], + pendingQuestions: questions + ? Object.values(questions.questionsBySession ?? {}).reduce( + (sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), + 0 + ) + : 0, + } +} + +function toProjectWidgets( + stats: ProjectStats[], + days: string[] +): ProjectWidgetData[] { + return stats .map((p) => ({ id: p.projectId, name: p.projectName, @@ -152,9 +213,11 @@ export async function buildWidgetData(): Promise { })) .sort((a, b) => (b.lastResponseAt ?? "").localeCompare(a.lastResponseAt ?? "")) .slice(0, 5) +} +function aggregateHistory(stats: ProjectStats[]): Record { const globalHistory: Record = {} - for (const p of Object.values(projectsMap)) { + for (const p of stats) { for (const [day, bucket] of Object.entries(p.dailyHistory ?? {})) { const agg = globalHistory[day] ?? { inputTokens: 0, @@ -169,7 +232,25 @@ export async function buildWidgetData(): Promise { globalHistory[day] = agg } } + return globalHistory +} +function buildData( + connections: Connection[], + sessions: Session[], + stats: ProjectStats[], + pendingQuestions: number +): WidgetData { + const connectedCount = connections.filter((c) => c.healthy !== false && c.url).length + const unreachableCount = connections.filter((c) => c.healthy === false).length + + const activeSession = sessions.length + ? [...sessions].sort((a, b) => b.time.updated - a.time.updated)[0] + : undefined + + const days = last7Days() + const today = todayKey() + const globalHistory = aggregateHistory(stats) const todayBucket = globalHistory[today] ?? { inputTokens: 0, outputTokens: 0, @@ -178,8 +259,10 @@ export async function buildWidgetData(): Promise { } return { - connected: Boolean(connected), - serverName: current?.name ?? "", + connected: connectedCount > 0, + connectedCount, + unreachableCount, + totalCount: connections.length, hasSession: Boolean(activeSession), sessionTitle: activeSession?.title ?? "", sessionAgent: activeSession?.agent ?? "", @@ -192,7 +275,55 @@ export async function buildWidgetData(): Promise { todayOutputTokens: todayBucket.outputTokens, todayCost: todayBucket.cost, weekOutputTokens: days.map((d) => globalHistory[d]?.outputTokens ?? 0), - projects, + projects: toProjectWidgets(stats, days), + } +} + +/** Overall stats across ALL connections. */ +export async function buildWidgetData(): Promise { + const { connections, sessions, projectsMap, pendingQuestions } = await loadState() + return buildData(connections, sessions, Object.values(projectsMap), pendingQuestions) +} + +/** + * Stats scoped to a single connection (chosen per widget instance in the + * config screen). Falls back to the aggregate when the id is missing so the + * widget never renders empty after a connection is deleted. + */ +export async function buildConnectionWidgetData( + connectionId: string | null +): Promise { + const { connections, sessions, projectsMap, storedProjects, pendingQuestions } = + await loadState() + const connection = connections.find((c) => c.id === connectionId) ?? null + + if (!connection) { + return { + ...(await buildWidgetData()), + connectionId: null, + connectionName: "", + connectionHealthy: null, + } + } + + const ownedProjectIds = new Set( + storedProjects.filter((p) => p.connectionId === connection.id).map((p) => p.id) + ) + const ownedDirectories = new Set( + storedProjects.filter((p) => p.connectionId === connection.id).map((p) => p.directory) + ) + const ownSessions = sessions.filter( + (s) => ownedProjectIds.has(s.projectID) || ownedDirectories.has(s.directory) + ) + const ownStats = Object.values(projectsMap).filter((p) => + ownedProjectIds.has(p.projectId) + ) + + return { + ...buildData([connection], ownSessions, ownStats, pendingQuestions), + connectionId: connection.id, + connectionName: connection.name, + connectionHealthy: connection.healthy ?? null, } } diff --git a/apps/mobile/lib/widget-task.tsx b/apps/mobile/lib/widget-task.tsx index e2f29d9..0ce8ddc 100644 --- a/apps/mobile/lib/widget-task.tsx +++ b/apps/mobile/lib/widget-task.tsx @@ -1,17 +1,31 @@ import { registerWidgetTaskHandler, requestWidgetUpdate } from "react-native-android-widget" -import { buildWidgetData } from "@/lib/widget-data" +import type { WidgetInfo } from "react-native-android-widget" +import { + buildConnectionWidgetData, + buildWidgetData, + getWidgetConnectionId, +} from "@/lib/widget-data" import SmallWidget from "@/widgets/SmallWidget" import MediumWidget from "@/widgets/MediumWidget" import LargeWidget from "@/widgets/LargeWidget" +import ConnectionWidget from "@/widgets/ConnectionWidget" -type WidgetData = Awaited> +type AggregateData = Awaited> -const WIDGETS: Record React.ReactElement> = { - SmallWidget, - MediumWidget, - LargeWidget, +function AggregateComponent(widgetName: string) { + switch (widgetName) { + case "MediumWidget": + return MediumWidget + case "LargeWidget": + return LargeWidget + default: + return SmallWidget + } } +const AGGREGATE_WIDGETS = ["SmallWidget", "MediumWidget", "LargeWidget"] +const CONNECTION_WIDGETS = ["ConnectionWidget"] + let registered = false export function registerCrossCodeWidgets() { @@ -21,23 +35,68 @@ export function registerCrossCodeWidgets() { registerWidgetTaskHandler(async ({ widgetInfo, widgetAction, renderWidget }) => { if (widgetAction === "WIDGET_DELETED") return - const Component = WIDGETS[widgetInfo.widgetName] - if (!Component) return + if ((CONNECTION_WIDGETS as string[]).includes(widgetInfo.widgetName)) { + const connectionId = await getWidgetConnectionId( + widgetInfo.widgetName, + widgetInfo.widgetId + ).catch(() => null) + const data = await buildConnectionWidgetData(connectionId) + renderWidget( + ( + + ) as never + ) + return + } + if (!(AGGREGATE_WIDGETS as string[]).includes(widgetInfo.widgetName)) return + const Component = AggregateComponent(widgetInfo.widgetName) const data = await buildWidgetData() - renderWidget( as never) + renderWidget( as never) }) } -export async function refreshWidgets() { +async function renderAggregateWidget(info: WidgetInfo) { + const Component = AggregateComponent(info.widgetName) const data = await buildWidgetData() + return as never +} + +async function renderConnectionWidget(info: WidgetInfo) { + const connectionId = await getWidgetConnectionId(info.widgetName, info.widgetId).catch( + () => null + ) + const data = await buildConnectionWidgetData(connectionId) + return ( + + ) as never +} + +export async function refreshWidgets() { + await Promise.all( + [...AGGREGATE_WIDGETS, ...CONNECTION_WIDGETS].map((name) => + requestWidgetUpdate({ + widgetName: name, + renderWidget: (info) => + (CONNECTION_WIDGETS as string[]).includes(name) + ? renderConnectionWidget(info) + : renderAggregateWidget(info), + }).catch(() => {}) + ) + ) +} + +export async function refreshConnectionWidgets() { await Promise.all( - Object.keys(WIDGETS).map((name) => { - const Component = WIDGETS[name] - return requestWidgetUpdate({ + CONNECTION_WIDGETS.map((name) => + requestWidgetUpdate({ widgetName: name, - renderWidget: () => as never, + renderWidget: renderConnectionWidget, }).catch(() => {}) - }) + ) ) } diff --git a/apps/mobile/store/connection.store.ts b/apps/mobile/store/connection.store.ts index 80e8eda..8c1f248 100644 --- a/apps/mobile/store/connection.store.ts +++ b/apps/mobile/store/connection.store.ts @@ -25,15 +25,18 @@ export type Connection = { type ConnectionStore = { connections: Connection[] + /** + * @deprecated No selected-connection concept anymore. Kept as a + * last-used fallback for legacy deep links / not-yet-migrated screens. + * New code must resolve the connection from route params or + * `project.connectionId` instead. + */ current: string | null - activeConnections: string[] - addConnection: (con: Omit) => void + addConnection: (con: Omit) => string removeConnection: (id: string) => void updateConnection: (id: string, updates: Partial>) => void setCurrent: (id: string) => void setConnectionHealth: (id: string, healthy: boolean) => void - toggleActiveConnection: (id: string) => void - setActiveConnections: (ids: string[]) => void } export const useConnections = create()( @@ -41,32 +44,31 @@ export const useConnections = create()( (set, get) => ({ connections: [], current: null, - activeConnections: [], - addConnection: (con) => - set((state) => { - const existing = state.connections.find((c) => c.url === con.url) - if (existing) { - if (existing.token !== con.token) clearAuthCache(existing.token) - return { - connections: state.connections.map((c) => - c.id === existing.id ? { ...c, ...con } : c - ), - current: existing.id, - } - } - const newCon = { ...con, id: uid(), added: Date.now(), healthy: null } - return { - connections: [...state.connections, newCon], - current: newCon.id, - } - }), + addConnection: (con) => { + const existing = get().connections.find((c) => c.url === con.url) + if (existing) { + if (existing.token !== con.token) clearAuthCache(existing.token) + set((state) => ({ + connections: state.connections.map((c) => + c.id === existing.id ? { ...c, ...con } : c + ), + current: existing.id, + })) + return existing.id + } + const newCon = { ...con, id: uid(), added: Date.now(), healthy: null } + set((state) => ({ + connections: [...state.connections, newCon], + current: newCon.id, + })) + return newCon.id + }, removeConnection: (id) => { const conn = get().connections.find((c) => c.id === id) if (conn) clearAuthCache(conn.token) set((state) => ({ connections: state.connections.filter((c) => c.id !== id), current: state.current === id ? null : state.current, - activeConnections: state.activeConnections.filter((a) => a !== id), })) }, updateConnection: (id, updates) => @@ -86,13 +88,6 @@ export const useConnections = create()( ), } }), - toggleActiveConnection: (id) => - set((state) => ({ - activeConnections: state.activeConnections.includes(id) - ? state.activeConnections.filter((a) => a !== id) - : [...state.activeConnections, id], - })), - setActiveConnections: (ids) => set({ activeConnections: ids }), }), { name: "crosscode-connections", @@ -100,7 +95,6 @@ export const useConnections = create()( partialize: (state) => ({ connections: state.connections.map(({ healthy, ...rest }) => rest), current: state.current, - activeConnections: state.activeConnections, }), } ) diff --git a/apps/mobile/widgets/ConnectionWidget.tsx b/apps/mobile/widgets/ConnectionWidget.tsx new file mode 100644 index 0000000..b6af7cf --- /dev/null +++ b/apps/mobile/widgets/ConnectionWidget.tsx @@ -0,0 +1,120 @@ +"use no memo"; +import { FlexWidget, TextWidget } from "react-native-android-widget" +import type { ConnectionWidgetData } from "../lib/widget-data" +import { + BarChart, + COLORS, + ConnectionStatusRow, + formatCost, + formatNumber, + formatRelativeTime, +} from "./shared" + +function configUri(widgetName: string, widgetId: number): string { + return `crosscode://widget-config?widgetName=${encodeURIComponent(widgetName)}&widgetId=${widgetId}` +} + +export default function ConnectionWidget( + props: ConnectionWidgetData & { widgetName: string; widgetId: number } +) { + if (!props.connectionId) { + return ( + + + + + ) + } + + return ( + + + + {props.hasSession ? ( + + + + + ) : ( + + )} + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/apps/mobile/widgets/shared.tsx b/apps/mobile/widgets/shared.tsx index cd7f70f..fa9ec68 100644 --- a/apps/mobile/widgets/shared.tsx +++ b/apps/mobile/widgets/shared.tsx @@ -86,9 +86,12 @@ export function BarChart({ } export function StatusRow({ data }: { data: WidgetData }) { - const label = data.connected - ? data.serverName || "Connected" - : "Not connected" + const label = + data.totalCount === 0 + ? "No connections" + : data.connected + ? `${data.connectedCount}/${data.totalCount} connected` + : "All unreachable" return ( ) } + +export function ConnectionStatusRow({ + name, + healthy, + pendingQuestions, +}: { + name: string + healthy: boolean | null + pendingQuestions: number +}) { + const connected = healthy !== false + const label = name || "Connection" + const badge = healthy === false ? "Unreachable" : healthy == null ? "Checking…" : "Connected" + const badgeColor = + healthy === false ? COLORS.disconnected : healthy == null ? COLORS.muted : COLORS.connected + return ( + + + + + {pendingQuestions > 0 ? ( + + ) : null} + + ) +}