From 2675c191e2a7297d639c1709e7385d5520db966a Mon Sep 17 00:00:00 2001 From: suraj-markup Date: Tue, 3 Mar 2026 01:46:35 +0530 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20add=20dashboard=20action=20pari?= =?UTF-8?q?ty=20=E2=80=94=20merge,=20CI=20fix,=20review=20comments,=20spaw?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds missing action features to match the web dashboard: - Merge PR button: green button when PR is mergeable, calls /api/prs/:id/merge - CI checks list: individual checks with status icons and links - "Ask to fix CI" button: sends "Please fix the failing CI checks" to agent - Unresolved review comments: per-comment card with author, path, body - "Ask Agent to Fix" per comment: sends context-specific message with file path - Alert cards: CI failures, merge conflicts, changes requested warnings - Spawn Session screen: create new sessions with project ID and optional issue ID - "+" button on home screen header to spawn sessions - PR title is now tappable (opens in browser) - Color-coded CI/review/mergeability status values - PR blockers list display Co-Authored-By: Claude Opus 4.6 --- .../mobile/src/context/BackendContext.tsx | 17 + .../mobile/src/navigation/RootNavigator.tsx | 7 + packages/mobile/src/screens/HomeScreen.tsx | 19 +- .../src/screens/SessionDetailScreen.tsx | 327 +++++++++++++++++- .../mobile/src/screens/SpawnSessionScreen.tsx | 173 +++++++++ packages/mobile/src/types/index.ts | 8 + 6 files changed, 531 insertions(+), 20 deletions(-) create mode 100644 packages/mobile/src/screens/SpawnSessionScreen.tsx diff --git a/packages/mobile/src/context/BackendContext.tsx b/packages/mobile/src/context/BackendContext.tsx index 542c2074c..2ec784949 100644 --- a/packages/mobile/src/context/BackendContext.tsx +++ b/packages/mobile/src/context/BackendContext.tsx @@ -19,6 +19,8 @@ interface BackendContextValue { sendMessage: (id: string, message: string) => Promise; killSession: (id: string) => Promise; restoreSession: (id: string) => Promise; + mergePR: (prNumber: number) => Promise; + spawnSession: (projectId: string, issueId?: string) => Promise; } const BackendContext = createContext(null); @@ -109,6 +111,19 @@ export function BackendProvider({ children }: { children: React.ReactNode }) { await apiFetch(`/api/sessions/${encodeURIComponent(id)}/restore`, { method: "POST" }); }, [apiFetch]); + const mergePR = useCallback(async (prNumber: number): Promise => { + await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" }); + }, [apiFetch]); + + const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise => { + const res = await apiFetch("/api/spawn", { + method: "POST", + body: JSON.stringify({ projectId, ...(issueId ? { issueId } : {}) }), + }); + const data = (await res.json()) as { session: DashboardSession }; + return data.session; + }, [apiFetch]); + return ( {children} diff --git a/packages/mobile/src/navigation/RootNavigator.tsx b/packages/mobile/src/navigation/RootNavigator.tsx index fd26b9907..551ed7ef4 100644 --- a/packages/mobile/src/navigation/RootNavigator.tsx +++ b/packages/mobile/src/navigation/RootNavigator.tsx @@ -5,12 +5,14 @@ import HomeScreen from "../screens/HomeScreen"; import SessionDetailScreen from "../screens/SessionDetailScreen"; import TerminalScreen from "../screens/TerminalScreen"; import SettingsScreen from "../screens/SettingsScreen"; +import SpawnSessionScreen from "../screens/SpawnSessionScreen"; export type RootStackParamList = { Home: undefined; SessionDetail: { sessionId: string }; Terminal: { sessionId: string; terminalWsUrl: string }; Settings: undefined; + SpawnSession: undefined; }; const Stack = createNativeStackNavigator(); @@ -61,6 +63,11 @@ export default function RootNavigator() { component={SettingsScreen} options={{ title: "Settings" }} /> + ); diff --git a/packages/mobile/src/screens/HomeScreen.tsx b/packages/mobile/src/screens/HomeScreen.tsx index da3a02454..a6efe1f54 100644 --- a/packages/mobile/src/screens/HomeScreen.tsx +++ b/packages/mobile/src/screens/HomeScreen.tsx @@ -38,12 +38,19 @@ export default function HomeScreen({ navigation }: Props) { React.useLayoutEffect(() => { navigation.setOptions({ headerRight: () => ( - navigation.navigate("Settings")} - style={{ paddingRight: 4 }} - > - Settings - + + navigation.navigate("SpawnSession")} + > + + + + navigation.navigate("Settings")} + style={{ paddingRight: 4 }} + > + Settings + + ), }); }, [navigation]); diff --git a/packages/mobile/src/screens/SessionDetailScreen.tsx b/packages/mobile/src/screens/SessionDetailScreen.tsx index a808bf84f..c946e664c 100644 --- a/packages/mobile/src/screens/SessionDetailScreen.tsx +++ b/packages/mobile/src/screens/SessionDetailScreen.tsx @@ -10,6 +10,7 @@ import { ActivityIndicator, KeyboardAvoidingView, Platform, + Linking, } from "react-native"; import type { NativeStackScreenProps } from "@react-navigation/native-stack"; import type { RootStackParamList } from "../navigation/RootNavigator"; @@ -22,16 +23,24 @@ import { isRestorable, isTerminal, ATTENTION_COLORS, + type DashboardCICheck, + type DashboardUnresolvedComment, + type DashboardPR, } from "../types"; type Props = NativeStackScreenProps; +type ActionButtonState = "idle" | "sending" | "sent" | "error"; + export default function SessionDetailScreen({ route, navigation }: Props) { const { sessionId } = route.params; const { session, loading, error, refresh } = useSession(sessionId); - const { sendMessage, killSession, restoreSession, terminalWsUrl } = useBackend(); + const { sendMessage, killSession, restoreSession, mergePR, terminalWsUrl } = useBackend(); const [message, setMessage] = useState(""); const [sending, setSending] = useState(false); + const [merging, setMerging] = useState(false); + const [ciFixState, setCiFixState] = useState("idle"); + const [commentFixStates, setCommentFixStates] = useState>({}); const handleSend = useCallback(async () => { if (!message.trim()) return; @@ -81,6 +90,45 @@ export default function SessionDetailScreen({ route, navigation }: Props) { navigation.navigate("Terminal", { sessionId, terminalWsUrl }); }, [navigation, sessionId, terminalWsUrl]); + const handleMergePR = useCallback(async (prNumber: number) => { + setMerging(true); + try { + await mergePR(prNumber); + Alert.alert("Merged", `PR #${prNumber} merged successfully.`); + refresh(); + } catch (err) { + Alert.alert("Merge failed", err instanceof Error ? err.message : "Failed to merge PR"); + } finally { + setMerging(false); + } + }, [mergePR, refresh]); + + const handleAskFixCI = useCallback(async (pr: DashboardPR) => { + setCiFixState("sending"); + try { + await sendMessage(sessionId, `Please fix the failing CI checks on ${pr.url}`); + setCiFixState("sent"); + setTimeout(() => setCiFixState("idle"), 3000); + } catch { + setCiFixState("error"); + setTimeout(() => setCiFixState("idle"), 3000); + } + }, [sendMessage, sessionId]); + + const handleAskFixComment = useCallback(async (comment: DashboardUnresolvedComment) => { + const key = comment.url; + setCommentFixStates((prev) => ({ ...prev, [key]: "sending" })); + try { + const msg = `Please address this review comment:\n\nFile: ${comment.path}\nComment: ${comment.body}\n\nComment URL: ${comment.url}\n\nAfter fixing, mark the comment as resolved at ${comment.url}`; + await sendMessage(sessionId, msg); + setCommentFixStates((prev) => ({ ...prev, [key]: "sent" })); + setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000); + } catch { + setCommentFixStates((prev) => ({ ...prev, [key]: "error" })); + setTimeout(() => setCommentFixStates((prev) => ({ ...prev, [key]: "idle" })), 3000); + } + }, [sendMessage, sessionId]); + if (loading && !session) { return ( @@ -106,6 +154,10 @@ export default function SessionDetailScreen({ route, navigation }: Props) { const color = ATTENTION_COLORS[level]; const canRestore = isRestorable(session); const isDone = isTerminal(session); + const pr = session.pr; + const isReadyToMerge = pr?.mergeability.mergeable && pr.state === "open"; + const failedChecks = pr?.ciChecks.filter((c) => c.status === "failed") ?? []; + const unresolvedComments = pr?.unresolvedComments ?? []; return ( + {/* Alerts */} + {pr && pr.ciStatus === "failing" && failedChecks.length > 0 && ( + + + + {failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing + + handleAskFixCI(pr)} + /> + + + )} + + {pr && !pr.mergeability.noConflicts && ( + + Merge conflict + + )} + + {pr && pr.reviewDecision === "changes_requested" && ( + + Changes requested + + )} + {/* Issue */} {(session.issueLabel || session.issueTitle) && (
@@ -153,21 +233,63 @@ export default function SessionDetailScreen({ route, navigation }: Props) { )} {/* PR */} - {session.pr && ( -
- - - - - {session.pr.additions > 0 && ( - + {pr && ( +
+ Linking.openURL(pr.url)}> + + {pr.title} + + + + + + + {pr.isDraft && } + {pr.mergeability.blockers.length > 0 && ( + + Blockers: + {pr.mergeability.blockers.map((b, i) => ( + - {b} + ))} + )}
)} + {/* CI Checks */} + {pr && pr.ciChecks.length > 0 && ( +
+ {pr.ciChecks.map((check, i) => ( + + ))} +
+ )} + + {/* Unresolved Comments */} + {unresolvedComments.length > 0 && ( +
+ {unresolvedComments.map((comment, i) => ( + + + {comment.author} + {comment.path} + + {comment.body} + + handleAskFixComment(comment)} + /> + Linking.openURL(comment.url)}> + View + + + + ))} +
+ )} + {/* Timestamps */}
@@ -176,6 +298,22 @@ export default function SessionDetailScreen({ route, navigation }: Props) { {/* Actions */} + {isReadyToMerge && ( + handleMergePR(pr.number)} + disabled={merging} + > + {merging ? ( + + ) : ( + + Merge PR #{pr.number} + + )} + + )} + Open Terminal @@ -234,15 +372,60 @@ function Section({ title, children }: { title: string; children: React.ReactNode ); } -function InfoRow({ label, value }: { label: string; value: string }) { +function InfoRow({ label, value, valueColor }: { label: string; value: string; valueColor?: string }) { return ( {label} - {value} + {value} ); } +const CI_STATUS_ICONS: Record = { + passed: { icon: "\u2713", color: "#3fb950" }, + failed: { icon: "\u2717", color: "#f85149" }, + running: { icon: "\u25CF", color: "#e3b341" }, + pending: { icon: "\u25CF", color: "#8b949e" }, + skipped: { icon: "\u2014", color: "#6e7681" }, +}; + +function CICheckRow({ check }: { check: DashboardCICheck }) { + const info = CI_STATUS_ICONS[check.status] ?? CI_STATUS_ICONS.pending; + return ( + Linking.openURL(check.url!) : undefined} + disabled={!check.url} + > + {info.icon} + {check.name} + {check.status} + + ); +} + +function ActionButton({ state, label, onPress }: { state: ActionButtonState; label: string; onPress: () => void }) { + const isDisabled = state === "sending" || state === "sent"; + const bgColor = state === "sent" ? "#1f3a2a" : state === "error" ? "#3d1f20" : "#21262d"; + const borderColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#30363d"; + const textColor = state === "sent" ? "#3fb950" : state === "error" ? "#f85149" : "#58a6ff"; + const text = state === "sending" ? "Sending..." : state === "sent" ? "Sent" : state === "error" ? "Failed" : label; + + return ( + + {state === "sending" ? ( + + ) : ( + {text} + )} + + ); +} + const styles = StyleSheet.create({ root: { flex: 1, @@ -286,6 +469,27 @@ const styles = StyleSheet.create({ color: "#8b949e", fontSize: 13, }, + // Alerts + alertCard: { + backgroundColor: "#3d1f20", + borderLeftWidth: 3, + borderLeftColor: "#f85149", + borderRadius: 8, + padding: 12, + marginBottom: 10, + }, + alertRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + alertText: { + color: "#f85149", + fontSize: 13, + fontWeight: "600", + flex: 1, + }, + // Sections section: { backgroundColor: "#161b22", borderRadius: 8, @@ -329,6 +533,97 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "500", }, + blockersLabel: { + color: "#f85149", + fontSize: 12, + fontWeight: "600", + marginBottom: 4, + }, + blockerText: { + color: "#f85149", + fontSize: 12, + marginLeft: 4, + marginBottom: 2, + }, + // CI Checks + ciCheckRow: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 6, + gap: 8, + }, + ciCheckIcon: { + fontSize: 14, + fontWeight: "700", + width: 18, + textAlign: "center", + }, + ciCheckName: { + color: "#e6edf3", + fontSize: 13, + flex: 1, + }, + ciCheckStatus: { + fontSize: 11, + fontWeight: "600", + }, + // Unresolved Comments + commentCard: { + backgroundColor: "#0d1117", + borderRadius: 6, + padding: 10, + marginBottom: 8, + borderWidth: 1, + borderColor: "#30363d", + }, + commentHeader: { + flexDirection: "row", + justifyContent: "space-between", + marginBottom: 6, + }, + commentAuthor: { + color: "#e6edf3", + fontSize: 12, + fontWeight: "600", + }, + commentPath: { + color: "#8b949e", + fontSize: 11, + fontFamily: "monospace", + flex: 1, + textAlign: "right", + marginLeft: 8, + }, + commentBody: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + marginBottom: 8, + }, + commentActions: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + }, + viewLink: { + color: "#58a6ff", + fontSize: 13, + fontWeight: "600", + }, + // Inline action button (for "Ask to fix", etc.) + inlineActionButton: { + borderWidth: 1, + borderRadius: 6, + paddingHorizontal: 10, + paddingVertical: 6, + minWidth: 80, + alignItems: "center", + }, + inlineActionText: { + fontSize: 12, + fontWeight: "600", + }, + // Main actions actionsSection: { gap: 8, marginTop: 4, @@ -339,6 +634,10 @@ const styles = StyleSheet.create({ alignItems: "center", borderWidth: 1, }, + mergeButton: { + backgroundColor: "#238636", + borderColor: "#2ea043", + }, terminalButton: { backgroundColor: "#21262d", borderColor: "#30363d", diff --git a/packages/mobile/src/screens/SpawnSessionScreen.tsx b/packages/mobile/src/screens/SpawnSessionScreen.tsx new file mode 100644 index 000000000..0d52a696c --- /dev/null +++ b/packages/mobile/src/screens/SpawnSessionScreen.tsx @@ -0,0 +1,173 @@ +import React, { useState, useCallback } from "react"; +import { + View, + Text, + TextInput, + TouchableOpacity, + StyleSheet, + Alert, + KeyboardAvoidingView, + Platform, + ScrollView, +} from "react-native"; +import type { NativeStackScreenProps } from "@react-navigation/native-stack"; +import type { RootStackParamList } from "../navigation/RootNavigator"; +import { useBackend } from "../context/BackendContext"; + +type Props = NativeStackScreenProps; + +export default function SpawnSessionScreen({ navigation }: Props) { + const { spawnSession } = useBackend(); + const [projectId, setProjectId] = useState(""); + const [issueId, setIssueId] = useState(""); + const [spawning, setSpawning] = useState(false); + + const handleSpawn = useCallback(async () => { + const trimmedProject = projectId.trim(); + if (!trimmedProject) { + Alert.alert("Invalid", "Project ID is required."); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(trimmedProject)) { + Alert.alert("Invalid", "Project ID must be alphanumeric (hyphens and underscores allowed)."); + return; + } + const trimmedIssue = issueId.trim(); + if (trimmedIssue && !/^[a-zA-Z0-9_-]+$/.test(trimmedIssue)) { + Alert.alert("Invalid", "Issue ID must be alphanumeric (hyphens and underscores allowed)."); + return; + } + + setSpawning(true); + try { + const session = await spawnSession(trimmedProject, trimmedIssue || undefined); + navigation.replace("SessionDetail", { sessionId: session.id }); + } catch (err) { + Alert.alert("Spawn failed", err instanceof Error ? err.message : "Failed to spawn session"); + } finally { + setSpawning(false); + } + }, [projectId, issueId, spawnSession, navigation]); + + return ( + + + + Spawn New Session + + Create a new agent session. The orchestrator will assign an agent and workspace. + + + Project ID * + + + + Issue ID{" "} + (optional) + + + + + + {spawning ? "Spawning..." : "Spawn Session"} + + + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + backgroundColor: "#0d1117", + }, + container: { + flex: 1, + }, + content: { + padding: 14, + }, + section: { + backgroundColor: "#161b22", + borderRadius: 10, + padding: 16, + }, + sectionTitle: { + color: "#8b949e", + fontSize: 11, + fontWeight: "700", + letterSpacing: 0.8, + textTransform: "uppercase", + marginBottom: 12, + }, + hint: { + color: "#8b949e", + fontSize: 13, + lineHeight: 18, + marginBottom: 16, + }, + fieldLabel: { + color: "#8b949e", + fontSize: 12, + fontWeight: "600", + marginBottom: 6, + }, + fieldLabelMuted: { + color: "#6e7681", + fontWeight: "400", + }, + input: { + backgroundColor: "#0d1117", + borderWidth: 1, + borderColor: "#30363d", + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + color: "#e6edf3", + fontSize: 14, + fontFamily: "monospace", + marginBottom: 14, + }, + spawnButton: { + backgroundColor: "#238636", + borderRadius: 8, + paddingVertical: 14, + alignItems: "center", + marginTop: 4, + }, + spawnButtonDisabled: { + backgroundColor: "#21262d", + }, + spawnButtonText: { + color: "#fff", + fontSize: 15, + fontWeight: "600", + }, +}); diff --git a/packages/mobile/src/types/index.ts b/packages/mobile/src/types/index.ts index 368e7488e..0a941bbdc 100644 --- a/packages/mobile/src/types/index.ts +++ b/packages/mobile/src/types/index.ts @@ -52,6 +52,13 @@ export interface DashboardMergeability { blockers: string[]; } +export interface DashboardUnresolvedComment { + url: string; + path: string; + author: string; + body: string; +} + export interface DashboardPR { number: number; url: string; @@ -69,6 +76,7 @@ export interface DashboardPR { reviewDecision: ReviewDecision; mergeability: DashboardMergeability; unresolvedThreads: number; + unresolvedComments?: DashboardUnresolvedComment[]; } export interface DashboardSession {