feat(mobile): add dashboard action parity — merge, CI fix, review comments, spawn

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 <noreply@anthropic.com>
This commit is contained in:
suraj-markup 2026-03-03 01:46:35 +05:30
parent c53099923d
commit 2675c191e2
6 changed files with 531 additions and 20 deletions

View File

@ -19,6 +19,8 @@ interface BackendContextValue {
sendMessage: (id: string, message: string) => Promise<void>;
killSession: (id: string) => Promise<void>;
restoreSession: (id: string) => Promise<void>;
mergePR: (prNumber: number) => Promise<void>;
spawnSession: (projectId: string, issueId?: string) => Promise<DashboardSession>;
}
const BackendContext = createContext<BackendContextValue | null>(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<void> => {
await apiFetch(`/api/prs/${prNumber}/merge`, { method: "POST" });
}, [apiFetch]);
const spawnSession = useCallback(async (projectId: string, issueId?: string): Promise<DashboardSession> => {
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 (
<BackendContext.Provider
value={{
@ -122,6 +137,8 @@ export function BackendProvider({ children }: { children: React.ReactNode }) {
sendMessage,
killSession,
restoreSession,
mergePR,
spawnSession,
}}
>
{children}

View File

@ -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<RootStackParamList>();
@ -61,6 +63,11 @@ export default function RootNavigator() {
component={SettingsScreen}
options={{ title: "Settings" }}
/>
<Stack.Screen
name="SpawnSession"
component={SpawnSessionScreen}
options={{ title: "New Session" }}
/>
</Stack.Navigator>
</NavigationContainer>
);

View File

@ -38,12 +38,19 @@ export default function HomeScreen({ navigation }: Props) {
React.useLayoutEffect(() => {
navigation.setOptions({
headerRight: () => (
<TouchableOpacity
onPress={() => navigation.navigate("Settings")}
style={{ paddingRight: 4 }}
>
<Text style={{ color: "#58a6ff", fontSize: 16 }}>Settings</Text>
</TouchableOpacity>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<TouchableOpacity
onPress={() => navigation.navigate("SpawnSession")}
>
<Text style={{ color: "#3fb950", fontSize: 22, fontWeight: "700" }}>+</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => navigation.navigate("Settings")}
style={{ paddingRight: 4 }}
>
<Text style={{ color: "#58a6ff", fontSize: 16 }}>Settings</Text>
</TouchableOpacity>
</View>
),
});
}, [navigation]);

View File

@ -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<RootStackParamList, "SessionDetail">;
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<ActionButtonState>("idle");
const [commentFixStates, setCommentFixStates] = useState<Record<string, ActionButtonState>>({});
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 (
<View style={styles.center}>
@ -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 (
<KeyboardAvoidingView
@ -128,6 +180,34 @@ export default function SessionDetailScreen({ route, navigation }: Props) {
</Text>
</View>
{/* Alerts */}
{pr && pr.ciStatus === "failing" && failedChecks.length > 0 && (
<View style={styles.alertCard}>
<View style={styles.alertRow}>
<Text style={styles.alertText}>
{failedChecks.length} CI check{failedChecks.length > 1 ? "s" : ""} failing
</Text>
<ActionButton
state={ciFixState}
label="Ask to fix"
onPress={() => handleAskFixCI(pr)}
/>
</View>
</View>
)}
{pr && !pr.mergeability.noConflicts && (
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
<Text style={[styles.alertText, { color: "#d29922" }]}>Merge conflict</Text>
</View>
)}
{pr && pr.reviewDecision === "changes_requested" && (
<View style={[styles.alertCard, { borderLeftColor: "#d29922" }]}>
<Text style={[styles.alertText, { color: "#d29922" }]}>Changes requested</Text>
</View>
)}
{/* Issue */}
{(session.issueLabel || session.issueTitle) && (
<Section title="Issue">
@ -153,21 +233,63 @@ export default function SessionDetailScreen({ route, navigation }: Props) {
)}
{/* PR */}
{session.pr && (
<Section title={`PR #${session.pr.number}`}>
<InfoRow label="Title" value={session.pr.title} />
<InfoRow label="CI" value={session.pr.ciStatus} />
<InfoRow label="Review" value={session.pr.reviewDecision} />
<InfoRow label="Mergeable" value={session.pr.mergeability.mergeable ? "Yes" : "No"} />
{session.pr.additions > 0 && (
<InfoRow
label="Changes"
value={`+${session.pr.additions} / -${session.pr.deletions}`}
/>
{pr && (
<Section title={`PR #${pr.number}`}>
<TouchableOpacity onPress={() => Linking.openURL(pr.url)}>
<Text style={[styles.issueText, { color: "#58a6ff", marginBottom: 8 }]} numberOfLines={2}>
{pr.title}
</Text>
</TouchableOpacity>
<InfoRow label="CI" value={pr.ciStatus} valueColor={pr.ciStatus === "passing" ? "#3fb950" : pr.ciStatus === "failing" ? "#f85149" : undefined} />
<InfoRow label="Review" value={pr.reviewDecision} valueColor={pr.reviewDecision === "approved" ? "#3fb950" : pr.reviewDecision === "changes_requested" ? "#f85149" : undefined} />
<InfoRow label="Mergeable" value={pr.mergeability.mergeable ? "Yes" : "No"} valueColor={pr.mergeability.mergeable ? "#3fb950" : "#f85149"} />
<InfoRow label="Changes" value={`+${pr.additions} / -${pr.deletions}`} />
{pr.isDraft && <InfoRow label="Draft" value="Yes" />}
{pr.mergeability.blockers.length > 0 && (
<View style={{ marginTop: 6 }}>
<Text style={styles.blockersLabel}>Blockers:</Text>
{pr.mergeability.blockers.map((b, i) => (
<Text key={i} style={styles.blockerText}>- {b}</Text>
))}
</View>
)}
</Section>
)}
{/* CI Checks */}
{pr && pr.ciChecks.length > 0 && (
<Section title="CI Checks">
{pr.ciChecks.map((check, i) => (
<CICheckRow key={i} check={check} />
))}
</Section>
)}
{/* Unresolved Comments */}
{unresolvedComments.length > 0 && (
<Section title={`Unresolved Comments (${unresolvedComments.length})`}>
{unresolvedComments.map((comment, i) => (
<View key={i} style={styles.commentCard}>
<View style={styles.commentHeader}>
<Text style={styles.commentAuthor}>{comment.author}</Text>
<Text style={styles.commentPath} numberOfLines={1}>{comment.path}</Text>
</View>
<Text style={styles.commentBody} numberOfLines={4}>{comment.body}</Text>
<View style={styles.commentActions}>
<ActionButton
state={commentFixStates[comment.url] ?? "idle"}
label="Ask Agent to Fix"
onPress={() => handleAskFixComment(comment)}
/>
<TouchableOpacity onPress={() => Linking.openURL(comment.url)}>
<Text style={styles.viewLink}>View</Text>
</TouchableOpacity>
</View>
</View>
))}
</Section>
)}
{/* Timestamps */}
<Section title="Timing">
<InfoRow label="Created" value={relativeTime(session.createdAt)} />
@ -176,6 +298,22 @@ export default function SessionDetailScreen({ route, navigation }: Props) {
{/* Actions */}
<View style={styles.actionsSection}>
{isReadyToMerge && (
<TouchableOpacity
style={[styles.actionButton, styles.mergeButton]}
onPress={() => handleMergePR(pr.number)}
disabled={merging}
>
{merging ? (
<ActivityIndicator color="#fff" size="small" />
) : (
<Text style={[styles.actionButtonText, { color: "#fff" }]}>
Merge PR #{pr.number}
</Text>
)}
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.actionButton, styles.terminalButton]} onPress={handleOpenTerminal}>
<Text style={styles.actionButtonText}>Open Terminal</Text>
</TouchableOpacity>
@ -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 (
<View style={styles.infoRow}>
<Text style={styles.infoLabel}>{label}</Text>
<Text style={styles.infoValue}>{value}</Text>
<Text style={[styles.infoValue, valueColor ? { color: valueColor } : undefined]}>{value}</Text>
</View>
);
}
const CI_STATUS_ICONS: Record<string, { icon: string; color: string }> = {
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 (
<TouchableOpacity
style={styles.ciCheckRow}
onPress={check.url ? () => Linking.openURL(check.url!) : undefined}
disabled={!check.url}
>
<Text style={[styles.ciCheckIcon, { color: info.color }]}>{info.icon}</Text>
<Text style={styles.ciCheckName} numberOfLines={1}>{check.name}</Text>
<Text style={[styles.ciCheckStatus, { color: info.color }]}>{check.status}</Text>
</TouchableOpacity>
);
}
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 (
<TouchableOpacity
style={[styles.inlineActionButton, { backgroundColor: bgColor, borderColor }]}
onPress={onPress}
disabled={isDisabled}
>
{state === "sending" ? (
<ActivityIndicator color="#58a6ff" size="small" />
) : (
<Text style={[styles.inlineActionText, { color: textColor }]}>{text}</Text>
)}
</TouchableOpacity>
);
}
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",

View File

@ -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<RootStackParamList, "SpawnSession">;
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 (
<KeyboardAvoidingView
style={styles.root}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.section}>
<Text style={styles.sectionTitle}>Spawn New Session</Text>
<Text style={styles.hint}>
Create a new agent session. The orchestrator will assign an agent and workspace.
</Text>
<Text style={styles.fieldLabel}>Project ID *</Text>
<TextInput
style={styles.input}
value={projectId}
onChangeText={setProjectId}
placeholder="e.g. my-project"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="next"
/>
<Text style={styles.fieldLabel}>
Issue ID{" "}
<Text style={styles.fieldLabelMuted}>(optional)</Text>
</Text>
<TextInput
style={styles.input}
value={issueId}
onChangeText={setIssueId}
placeholder="e.g. 42 or PROJ-123"
placeholderTextColor="#8b949e"
autoCapitalize="none"
autoCorrect={false}
returnKeyType="done"
/>
<TouchableOpacity
style={[styles.spawnButton, spawning && styles.spawnButtonDisabled]}
onPress={handleSpawn}
disabled={spawning}
>
<Text style={styles.spawnButtonText}>
{spawning ? "Spawning..." : "Spawn Session"}
</Text>
</TouchableOpacity>
</View>
</ScrollView>
</KeyboardAvoidingView>
);
}
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",
},
});

View File

@ -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 {