feat: add CI check status to workspace sidebar and hover card

Extend the existing gh pr view call with statusCheckRollup and
reviewDecision fields so check data arrives in a single request.
The workspace row now shows an aggregate check icon (green checkmark,
red X, or amber dot) next to the PR badge, and the hover card lists
each individual check with its status and a link to details.
This commit is contained in:
Mohamed Boudra
2026-04-06 10:54:56 +07:00
parent eabd561fde
commit f75e28a77f
5 changed files with 282 additions and 1 deletions
@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import {
Archive,
Check,
CircleAlert,
ChevronDown,
ChevronRight,
@@ -40,6 +41,7 @@ import {
MoreVertical,
Plus,
Trash2,
X,
} from "lucide-react-native";
import { NestableScrollContainer } from "react-native-draggable-flatlist";
import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list";
@@ -224,6 +226,29 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
);
}
function ChecksStatusIcon({ checksStatus }: { checksStatus?: PrHint["checksStatus"] }) {
const { theme } = useUnistyles();
if (!checksStatus || checksStatus === "none") return null;
if (checksStatus === "success") {
return <Check size={11} color={theme.colors.palette.green[500]} strokeWidth={3} />;
}
if (checksStatus === "failure") {
return <X size={11} color={theme.colors.palette.red[500]} strokeWidth={3} />;
}
// pending
return (
<View
style={{
width: 7,
height: 7,
borderRadius: 3.5,
backgroundColor: theme.colors.palette.amber[500],
}}
/>
);
}
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -1042,6 +1067,7 @@ function WorkspaceRowInner({
{prHint ? (
<View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} />
<ChecksStatusIcon checksStatus={prHint.checksStatus} />
</View>
) : null}
</Pressable>
@@ -2410,6 +2436,9 @@ const styles = StyleSheet.create((theme) => ({
opacity: 1,
},
workspacePrBadgeRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2],
},
workspacePrBadge: {
@@ -337,6 +337,46 @@ function WorkspaceHoverCardContent({
</Text>
</Pressable>
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<View style={styles.checksList}>
{prHint.checks.map((check) => (
<Pressable
key={check.name}
style={({ hovered }) => [
styles.serviceRow,
hovered && check.url && styles.serviceRowHovered,
]}
onPress={check.url ? () => void openExternalUrl(check.url!) : undefined}
disabled={!check.url}
>
<View
style={[
styles.statusDot,
{
backgroundColor:
check.status === "success"
? theme.colors.palette.green[500]
: check.status === "failure" || check.status === "cancelled"
? theme.colors.palette.red[500]
: check.status === "pending"
? theme.colors.palette.amber[500]
: theme.colors.foregroundMuted,
},
]}
/>
<Text
style={styles.checkName}
numberOfLines={1}
>
{check.name}
</Text>
{check.url ? (
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
) : null}
</Pressable>
))}
</View>
) : null}
<View style={styles.separator} />
<View style={styles.serviceList} testID="hover-card-service-list">
{workspace.services.map((service) => (
@@ -502,4 +542,13 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
checksList: {
paddingBottom: theme.spacing[1],
},
checkName: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flex: 1,
minWidth: 0,
},
}));
@@ -20,6 +20,9 @@ export interface PrHint {
url: string;
number: number;
state: "open" | "merged" | "closed";
checks?: Array<{ name: string; status: string; url: string | null }>;
checksStatus?: "none" | "pending" | "success" | "failure";
reviewDecision?: "approved" | "changes_requested" | "pending" | null;
}
function parsePullRequestNumber(url: string): number | null {
@@ -56,6 +59,9 @@ function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null
: status.state === "open"
? "open"
: "closed",
checks: status.checks,
checksStatus: status.checksStatus as PrHint["checksStatus"],
reviewDecision: status.reviewDecision as PrHint["reviewDecision"],
};
}