From 35ed62ad0f1509c5f506eb88fc98577e53992efb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 22 Mar 2026 18:51:40 +0700 Subject: [PATCH] feat(sidebar): add PR status hint badge to workspace rows --- .../src/components/sidebar-workspace-list.tsx | 256 ++++++++++++------ .../src/hooks/use-checkout-pr-status-query.ts | 68 +++++ 2 files changed, 240 insertions(+), 84 deletions(-) diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 59be73978..581fd1c3e 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -33,6 +33,7 @@ import { CircleHelp, Copy, FolderGit2, + GitPullRequest, Monitor, MoreVertical, Plus, @@ -78,11 +79,13 @@ import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; +import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { createNameId } from "mnemonic-id"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; +import { openExternalUrl } from "@/utils/open-external-url"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { @@ -95,6 +98,12 @@ const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.wo const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; const EMPTY_WORKSPACES = new Map(); +const WORKSPACE_STATUS_DOT_WIDTH = 14; +const GITHUB_PR_STATE_COLORS: Record = { + open: "#3fb950", + merged: "#a371f7", + closed: "#f85149", +}; interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; @@ -153,6 +162,45 @@ interface WorkspaceRowInnerProps { archiveShortcutKeys?: ShortcutKey[][] | null; } +function WorkspacePrBadge({ hint }: { hint: PrHint }) { + const color = GITHUB_PR_STATE_COLORS[hint.state]; + + const handlePressIn = useCallback((event: GestureResponderEvent) => { + event.stopPropagation(); + }, []); + + const handlePress = useCallback( + (event: GestureResponderEvent) => { + event.stopPropagation(); + void openExternalUrl(hint.url); + }, + [hint.url], + ); + + return ( + [ + styles.workspacePrBadge, + { + borderColor: color, + backgroundColor: `${color}1a`, + }, + pressed && styles.workspacePrBadgePressed, + ]} + > + + + #{hint.number} + + + ); +} + function resolveStatusDotColor(input: { theme: ReturnType["theme"]; bucket: SidebarStateBucket; @@ -743,6 +791,11 @@ function WorkspaceRowInner({ const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isMobile = Platform.OS !== "web"; + const prHint = useWorkspacePrHint({ + serverId: workspace.serverId, + cwd: workspace.workspaceId, + enabled: workspace.workspaceKind !== "directory", + }); const interaction = useLongPressDragInteraction({ drag, menuController, @@ -777,92 +830,99 @@ function WorkspaceRowInner({ onPress={handlePress} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > - - - + - {workspace.name} - - - - {isCreating ? Creating... : null} - {onArchive && (isHovered || isMobile) ? ( - - [ - styles.kebabButton, - hovered && styles.kebabButtonHovered, - ]} - accessibilityRole="button" - accessibilityLabel="Workspace actions" - testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} - > - {({ hovered }) => ( - - )} - - - {onCopyPath ? ( - } - onSelect={onCopyPath} - > - Copy path - - ) : null} - {onCopyBranchName ? ( - } - onSelect={onCopyBranchName} - > - Copy branch name - - ) : null} - } - trailing={archiveShortcutKeys ? : null} - status={archiveStatus} - pendingLabel={archivePendingLabel} - onSelect={onArchive} + + + {workspace.name} + + + + {isCreating ? Creating... : null} + {onArchive && (isHovered || isMobile) ? ( + + [ + styles.kebabButton, + hovered && styles.kebabButtonHovered, + ]} + accessibilityRole="button" + accessibilityLabel="Workspace actions" + testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > - {archiveLabel ?? "Archive"} - - - - ) : workspace.diffStat ? ( - - +{workspace.diffStat.additions} - -{workspace.diffStat.deletions} - - ) : null} - {showShortcutBadge && shortcutNumber !== null ? ( - - {shortcutNumber} - - ) : null} + {({ hovered }) => ( + + )} + + + {onCopyPath ? ( + } + onSelect={onCopyPath} + > + Copy path + + ) : null} + {onCopyBranchName ? ( + } + onSelect={onCopyBranchName} + > + Copy branch name + + ) : null} + } + trailing={archiveShortcutKeys ? : null} + status={archiveStatus} + pendingLabel={archivePendingLabel} + onSelect={onArchive} + > + {archiveLabel ?? "Archive"} + + + + ) : workspace.diffStat ? ( + + +{workspace.diffStat.additions} + -{workspace.diffStat.deletions} + + ) : null} + {showShortcutBadge && shortcutNumber !== null ? ( + + {shortcutNumber} + + ) : null} + + {prHint ? ( + + + + ) : null} ); @@ -2001,10 +2061,17 @@ const styles = StyleSheet.create((theme) => ({ paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, + flexDirection: "column", + alignItems: "stretch", + justifyContent: "center", + gap: theme.spacing[1], + }, + workspaceRowMain: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], + width: "100%", }, workspaceRowLeft: { flexDirection: "row", @@ -2044,7 +2111,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceStatusDot: { - width: 14, + width: WORKSPACE_STATUS_DOT_WIDTH, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, @@ -2085,6 +2152,27 @@ const styles = StyleSheet.create((theme) => ({ workspaceBranchTextHovered: { opacity: 1, }, + workspacePrBadgeRow: { + paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], + }, + workspacePrBadge: { + alignSelf: "flex-start", + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1], + paddingHorizontal: theme.spacing[2], + paddingVertical: 3, + borderRadius: theme.borderRadius.full, + borderWidth: 1, + }, + workspacePrBadgePressed: { + opacity: 0.82, + }, + workspacePrBadgeText: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + lineHeight: 14, + }, workspaceCreatingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/hooks/use-checkout-pr-status-query.ts b/packages/app/src/hooks/use-checkout-pr-status-query.ts index dcdfa2957..3e9e789a3 100644 --- a/packages/app/src/hooks/use-checkout-pr-status-query.ts +++ b/packages/app/src/hooks/use-checkout-pr-status-query.ts @@ -3,6 +3,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import type { CheckoutPrStatusResponse } from "@server/shared/messages"; const CHECKOUT_PR_STATUS_STALE_TIME = 20_000; +const WORKSPACE_PR_HINT_REFETCH_INTERVAL = 60_000; function checkoutPrStatusQueryKey(serverId: string, cwd: string) { return ["checkoutPrStatus", serverId, cwd] as const; @@ -15,6 +16,48 @@ interface UseCheckoutPrStatusQueryOptions { } export type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; +export interface PrHint { + url: string; + number: number; + state: "open" | "merged" | "closed"; +} + +function parsePullRequestNumber(url: string): number | null { + try { + const pathname = new URL(url).pathname; + const match = pathname.match(/\/pull\/(\d+)(?:\/|$)/); + if (!match) { + return null; + } + + const number = Number.parseInt(match[1], 10); + return Number.isFinite(number) ? number : null; + } catch { + return null; + } +} + +function selectWorkspacePrHint(payload: CheckoutPrStatusPayload): PrHint | null { + const status = payload.status; + if (!status?.url) { + return null; + } + + const number = parsePullRequestNumber(status.url); + if (number === null) { + return null; + } + + return { + url: status.url, + number, + state: status.isMerged || status.state === "MERGED" + ? "merged" + : status.state === "OPEN" + ? "open" + : "closed", + }; +} export function useCheckoutPrStatusQuery({ serverId, @@ -48,3 +91,28 @@ export function useCheckoutPrStatusQuery({ refresh: query.refetch, }; } + +export function useWorkspacePrHint({ + serverId, + cwd, + enabled = true, +}: UseCheckoutPrStatusQueryOptions): PrHint | null { + const client = useHostRuntimeClient(serverId); + const isConnected = useHostRuntimeIsConnected(serverId); + + const query = useQuery({ + queryKey: checkoutPrStatusQueryKey(serverId, cwd), + queryFn: async () => { + if (!client) { + throw new Error("Daemon client not available"); + } + return await client.checkoutPrStatus(cwd); + }, + enabled: !!client && isConnected && !!cwd && enabled, + staleTime: CHECKOUT_PR_STATUS_STALE_TIME, + refetchInterval: WORKSPACE_PR_HINT_REFETCH_INTERVAL, + select: selectWorkspacePrHint, + }); + + return query.data ?? null; +}