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:
parent
c53099923d
commit
2675c191e2
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue