import { useCallback, useMemo, useState } from "react"; import { Image, Pressable, ScrollView, Text, View, type ViewStyle } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { CircleCheck, CircleX, Copy, ExternalLink, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, MessageSquare, MessageSquarePlus, MoreHorizontal, RotateCw, } from "lucide-react-native"; import type { PressableStateCallbackType } from "react-native"; import { useTranslation } from "react-i18next"; import { openExternalUrl } from "@/utils/open-external-url"; import { Button } from "@/components/ui/button"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { StatusBadge } from "@/components/ui/status-badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { MarkdownRenderer } from "@/components/markdown/renderer"; import { getDefaultMarkdownClipboardEnvironment } from "@/utils/rich-clipboard-default-environment"; import { writeMarkdownToRichClipboard } from "@/utils/rich-clipboard"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceAttachmentsStore } from "@/attachments/workspace-attachments-store"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { isNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { PaneContentToolbar, paneContentToolbarIconSize, paneContentToolbarIconButtonStyle, } from "@/components/ui/pane-content-toolbar"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { getForgePresentation } from "@/git/forge"; import { CLIENT_FORGE_VIEW_MODULES } from "@/git/forges/view"; import type { PaneNativeContribution } from "@/git/client-forge-module"; import { PrActivitySkeleton } from "./activity-skeleton"; import { collapseActivity, expandActivity, getActivityState, getCollapsedEntryIds, getVisibleEntries, } from "./activity-state"; import { formatPullRequestThreadPath } from "./activity-location"; import { buildPullRequestCommentContextAttachment, buildPullRequestCheckContextAttachment, buildPullRequestReviewContextAttachment, buildPullRequestThreadContextAttachment, canAddPullRequestActivityToChat, } from "./context-attachment"; import { ChecksSection, getCheckIdentity } from "./checks-section"; import { getActivityVerb, getStateLabel } from "./data"; import type { PrPaneActivity, PrPaneCheck, PrPaneData, PrState } from "./data"; import type { ForgeSpecificStatusFacts } from "@/git/merge-capability"; import { CheckPresentationIcon } from "@/git/check-presentation.view"; import { buildPrTimeline, type PrReviewEntry, type PrThreadEntry, type PrTimelineEntry, } from "./timeline"; import { Section, SummaryPill, dangerColorMapping, foregroundMutedColorMapping, sectionKitStyles, successColorMapping, } from "./section-kit"; const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleX = withUnistyles(CircleX); const ThemedCopy = withUnistyles(Copy); const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed); const ThemedGitPullRequestDraft = withUnistyles(GitPullRequestDraft); const ThemedMessageSquare = withUnistyles(MessageSquare); const ThemedMessageSquarePlus = withUnistyles(MessageSquarePlus); const ThemedMoreHorizontal = withUnistyles(MoreHorizontal); const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mergedColorMapping = (theme: Theme) => ({ color: theme.colors.statusMerged }); const CLIENT_PANE_CONTRIBUTIONS: readonly PaneNativeContribution[] = CLIENT_FORGE_VIEW_MODULES.flatMap((module) => module.paneContributions ?? []); function resolvePaneContribution( facts: ForgeSpecificStatusFacts | undefined, ): PaneNativeContribution | null { if (!facts) { return null; } return CLIENT_PANE_CONTRIBUTIONS.find((contribution) => contribution.guard(facts)) ?? null; } type IconColorMapping = typeof foregroundColorMapping; interface PrStatePresentation { Icon: typeof ThemedGitPullRequest; iconColor: IconColorMapping; } const PR_STATE_PRESENTATION: Record = { open: { Icon: ThemedGitPullRequest, iconColor: successColorMapping }, draft: { Icon: ThemedGitPullRequestDraft, iconColor: foregroundMutedColorMapping }, merged: { Icon: ThemedGitMerge, iconColor: mergedColorMapping }, closed: { Icon: ThemedGitPullRequestClosed, iconColor: dangerColorMapping }, }; const SUMMARY_COMMENT_ICON = ( ); const SUMMARY_APPROVAL_ICON = ; const SUMMARY_CHANGES_REQUESTED_ICON = ; const ADD_TO_CHAT_MENU_ICON = ( ); const COPY_MENU_ICON = ; const OPEN_MENU_ICON = ; function handleMarkdownLinkPress(url: string): boolean { void openExternalUrl(url); return false; } function entryHeaderPressableStyle({ hovered }: { hovered?: boolean }) { return [styles.entryHeaderPressable, Boolean(hovered) && styles.hoverable]; } function kebabTriggerStyle({ hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.kebabButton, hovered && styles.kebabButtonHovered]; } function refreshButtonStyle( { hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }, isCompact: boolean, ) { return paneContentToolbarIconButtonStyle({ hovered, pressed }, false, isCompact); } function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( ); } function addLoadingCheck(current: ReadonlySet, checkKey: string): ReadonlySet { if (current.has(checkKey)) { return current; } const next = new Set(current); next.add(checkKey); return next; } function removeLoadingCheck(current: ReadonlySet, checkKey: string): ReadonlySet { if (!current.has(checkKey)) { return current; } const next = new Set(current); next.delete(checkKey); return next; } export function PullRequestPane({ serverId, cwd, data, activityLoading, workspaceAttachmentScopeKey, }: { serverId: string; cwd: string; data: PrPaneData; activityLoading: boolean; workspaceAttachmentScopeKey?: string; }) { const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const toolbarRefreshButtonStyle = useCallback( (state: PressableStateCallbackType) => refreshButtonStyle(state, isCompact), [isCompact], ); const toast = useToast(); const daemonClient = useHostRuntimeClient(serverId); // COMPAT(githubCheckDetailsRpc): recognize the legacy capability on daemons // predating checkout.forge.get_check_details.*. Remove after 2027-01-17 once // the supported daemon floor is >= v0.2.0. const canFetchGitHubCheckDetails = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true, ); const canFetchForgeCheckDetails = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeCheckDetails === true, ); const forgeProvidersEnabled = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.forgeProviders === true, ); const addWorkspaceAttachment = useWorkspaceAttachmentsStore( (state) => state.addWorkspaceAttachment, ); const [checksOpen, setChecksOpen] = useState(true); const [activityOpen, setActivityOpen] = useState(true); const [activityState, setActivityState] = useState(getActivityState); const [loadingCheckKeys, setLoadingCheckKeys] = useState>(() => new Set()); const handleOpenPrUrl = useCallback(() => { void openExternalUrl(data.url); }, [data.url]); const refreshSupported = useSessionStore( (state) => state.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, ); const runRefresh = useCheckoutGitActionsStore((state) => state.refresh); const isRefreshing = useCheckoutGitActionsStore((state) => state.getStatus({ serverId, cwd, actionId: "refresh" }), ) === "pending"; const handleRefresh = useCallback(() => { if (isRefreshing) { return; } void runRefresh({ serverId, cwd }).catch((error) => { toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh")); }); }, [cwd, isRefreshing, runRefresh, serverId, t, toast]); const handleToggleChecks = useCallback(() => { setChecksOpen((open) => !open); }, []); const handleToggleActivity = useCallback(() => { setActivityOpen((open) => !open); }, []); const approvals = data.activity.filter( (item) => item.kind === "review" && item.reviewState === "approved", ).length; const changesRequested = data.activity.filter( (item) => item.kind === "review" && item.reviewState === "changes_requested", ).length; const commentCount = data.activity.filter( (item) => item.kind === "comment" || (item.kind === "review" && item.reviewState === "commented"), ).length; const timelineEntries = useMemo(() => buildPrTimeline(data.activity), [data.activity]); const visibleEntries = useMemo( () => getVisibleEntries(activityState, { prNumber: data.number, entries: timelineEntries, }), [activityState, data.number, timelineEntries], ); const collapsedEntryIds = useMemo( () => getCollapsedEntryIds(activityState, { prNumber: data.number, entries: timelineEntries }), [activityState, data.number, timelineEntries], ); const attachEnabled = workspaceAttachmentScopeKey !== undefined; const handleAddActivityToChat = useCallback( (activity: PrPaneActivity) => { if (!workspaceAttachmentScopeKey || !canAddPullRequestActivityToChat(activity)) { return; } const input = { provider: data.provider, forge: data.forge, pullRequest: { number: data.number, title: data.title, url: data.url }, activity, }; const attachment = activity.kind === "comment" ? buildPullRequestCommentContextAttachment(input) : buildPullRequestReviewContextAttachment(input); if (!attachment) { return; } addWorkspaceAttachment({ scopeKey: workspaceAttachmentScopeKey, attachment, }); }, [ addWorkspaceAttachment, data.forge, data.number, data.provider, data.title, data.url, workspaceAttachmentScopeKey, ], ); const handleAddThreadToChat = useCallback( (thread: PrThreadEntry) => { if (!workspaceAttachmentScopeKey) { return; } const attachment = buildPullRequestThreadContextAttachment({ provider: data.provider, forge: data.forge, pullRequest: { number: data.number, title: data.title, url: data.url }, thread, }); if (!attachment) { return; } addWorkspaceAttachment({ scopeKey: workspaceAttachmentScopeKey, attachment, }); }, [ addWorkspaceAttachment, data.forge, data.number, data.provider, data.title, data.url, workspaceAttachmentScopeKey, ], ); const handleAddAllToChat = useCallback(() => { for (const { entry } of visibleEntries) { if (entry.kind === "single") { handleAddActivityToChat(entry.activity); continue; } if (entry.kind === "review") { handleAddActivityToChat(entry.review); } const threads = entry.kind === "thread" ? [entry] : entry.threads; for (const thread of threads) { if (thread.isResolved === true) { continue; } handleAddThreadToChat(thread); } } }, [handleAddActivityToChat, handleAddThreadToChat, visibleEntries]); const handleAddCheckLogsToChat = useCallback( async (check: PrPaneCheck) => { if (!workspaceAttachmentScopeKey) { return; } const checkKey = getCheckIdentity(check); setLoadingCheckKeys((current) => addLoadingCheck(current, checkKey)); let details = null; try { const ref = check.detailRef; // The neutral forge RPC fetches detail for any forge; fall back to the // legacy github-only RPC only for GitHub against a daemon that predates // it. A non-GitHub forge therefore needs the neutral capability present. const canFetchDetail = canFetchForgeCheckDetails || (check.provider === "github" && canFetchGitHubCheckDetails); if ( canFetchDetail && daemonClient && (ref?.checkRunId !== undefined || ref?.workflowRunId !== undefined) && data.repoOwner && data.repoName ) { try { const request = { cwd, repoOwner: data.repoOwner, repoName: data.repoName, checkRunId: ref.checkRunId, workflowRunId: ref.workflowRunId, changeRequestNumber: data.number, }; // COMPAT(githubCheckDetailsRpc): use the legacy GitHub RPC with // daemons predating checkout.forge.get_check_details.*. Remove after // 2027-01-17 once the supported daemon floor is >= v0.2.0. const payload = canFetchForgeCheckDetails ? await daemonClient.checkoutForgeGetCheckDetails(request) : await daemonClient.checkoutGithubGetCheckDetails(request); details = payload.success ? payload.details : null; } catch { details = null; } } const attachment = buildPullRequestCheckContextAttachment({ provider: data.provider, forge: data.forge, pullRequest: { number: data.number, title: data.title, url: data.url }, check, githubDetails: details, }); addWorkspaceAttachment({ scopeKey: workspaceAttachmentScopeKey, attachment, }); } catch { // The check row should recover even if attachment formatting or insertion fails. } finally { setLoadingCheckKeys((current) => removeLoadingCheck(current, checkKey)); } }, [ addWorkspaceAttachment, canFetchForgeCheckDetails, canFetchGitHubCheckDetails, cwd, daemonClient, data.forge, data.number, data.provider, data.repoName, data.repoOwner, data.title, data.url, workspaceAttachmentScopeKey, ], ); const handleToggleEntryCollapsed = useCallback( (entryId: string, collapsed: boolean) => { setActivityState((current) => { const identity = { prNumber: data.number, activityId: entryId }; return collapsed ? expandActivity(current, identity) : collapseActivity(current, identity); }); }, [data.number], ); const statePresentation = PR_STATE_PRESENTATION[data.state]; const StateIcon = statePresentation.Icon; const forgePresentation = getForgePresentation(data.forge); const repoIdentity = data.projectPath ?? (data.repoOwner && data.repoName ? `${data.repoOwner}/${data.repoName}` : null); // Native forge surfaces (e.g. GitLab approvals/pipeline) come from a registry // keyed by the facts-family, so the central render has no per-forge branch. const nativeContribution = resolvePaneContribution(data.forgeSpecific); const nativeHeaderMeta = data.forgeSpecific ? nativeContribution?.renderHeaderMeta(data.forgeSpecific) : null; const nativeChecksSection = data.forgeSpecific ? nativeContribution?.renderChecksSection(data.forgeSpecific, { serverId, cwd, changeRequestNumber: data.number, open: checksOpen, onToggle: handleToggleChecks, enabled: forgeProvidersEnabled, canFetchCheckDetails: canFetchForgeCheckDetails, }) : null; return ( {refreshSupported ? ( {isRefreshing ? ( ) : ( )} ) : null} {({ hovered }) => ( <> {data.title} {" "} {forgePresentation.numberPrefix} {data.number} {getStateLabel(data.state)} {nativeHeaderMeta} {repoIdentity ? ( {repoIdentity} ) : null} )} {nativeChecksSection ?? ( )}
} > {timelineEntries.length > 0 && attachEnabled && visibleEntries.length > 0 ? ( ) : null} {activityLoading ? : null} {!activityLoading && visibleEntries.length === 0 ? ( No activity yet ) : null} {!activityLoading ? visibleEntries.map(({ entry, collapsed }) => ( )) : null}
); } function stateLabelStyle(state: PrState) { if (state === "open") return styles.stateLabelOpen; if (state === "draft") return styles.stateLabelDraft; if (state === "merged") return styles.stateLabelMerged; return styles.stateLabelClosed; } interface TimelineEntryCallbacks { attachEnabled: boolean; brandLabel: string; onAddToChat: (activity: PrPaneActivity) => void; onAddThreadToChat: (thread: PrThreadEntry) => void; onToggleCollapsed: (entryId: string, collapsed: boolean) => void; } function TimelineEntryCard({ entry, collapsed, collapsedEntryIds, ...callbacks }: TimelineEntryCallbacks & { entry: PrTimelineEntry; collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { if (entry.kind === "thread") { return ; } if (entry.kind === "review") { return ( ); } return ; } function useRevealOnHover() { const isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const actionsVisible = isHovered || menuOpen || isNative || isCompact; return { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen }; } function ActivityKebab({ activity, visible, attachEnabled, brandLabel, onMenuOpenChange, onAddToChat, }: { activity: PrPaneActivity; visible: boolean; attachEnabled: boolean; brandLabel: string; onMenuOpenChange: (open: boolean) => void; onAddToChat: (activity: PrPaneActivity) => void; }) { const { t } = useTranslation(); const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]); const handleCopy = useCallback(() => { void writeMarkdownToRichClipboard(activity.body, getDefaultMarkdownClipboardEnvironment()); }, [activity.body]); const handleOpen = useCallback(() => { void openExternalUrl(activity.url); }, [activity.url]); return ( {renderKebabTriggerIcon} {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( Add to chat ) : null} {activity.body.trim() !== "" ? ( Copy ) : null} {t("workspace.git.pr.actions.openOn", { brand: brandLabel })} ); } function kebabSlotStyle(visible: boolean) { return visible ? styles.kebabSlot : styles.kebabSlotHidden; } function ActivityAvatar({ activity, size }: { activity: PrPaneActivity; size: number }) { const frameStyle = useMemo( () => [ styles.avatar, { width: size, height: size, borderRadius: size / 2, backgroundColor: activity.avatarColor, }, ], [activity.avatarColor, size], ); const imageStyle = useMemo(() => ({ width: size, height: size, borderRadius: size / 2 }), [size]); const imageSource = useMemo( () => (activity.avatarUrl ? { uri: activity.avatarUrl } : null), [activity.avatarUrl], ); return ( {imageSource ? ( ) : ( {activity.author.slice(0, 1).toUpperCase()} )} ); } function ActivityVerb({ activity }: { activity: PrPaneActivity }) { const verb = getActivityVerb(activity).toLowerCase(); if (activity.kind === "review" && activity.reviewState === "approved") { return ( {verb} ); } if (activity.kind === "review" && activity.reviewState === "changes_requested") { return ( {verb} ); } return {verb}; } function ActivityHeader({ activity, avatarSize, children, }: { activity: PrPaneActivity; avatarSize: number; children?: React.ReactNode; }) { return ( <> {activity.author} {activity.age} {children} ); } function SingleActivityCard({ entry, collapsed, attachEnabled, brandLabel, onAddToChat, onToggleCollapsed, }: TimelineEntryCallbacks & { entry: Extract; collapsed: boolean; }) { const { activity } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); const hasBody = activity.body.trim() !== ""; const handleAddToChat = useCallback(() => onAddToChat(activity), [activity, onAddToChat]); const handleHeaderPress = useCallback(() => { if (hasBody) { onToggleCollapsed(entry.id, collapsed); return; } void openExternalUrl(activity.url); }, [activity.url, collapsed, entry.id, hasBody, onToggleCollapsed]); if (!hasBody) { return ( ); } return ( {collapsed ? null : ( <> {attachEnabled && canAddPullRequestActivityToChat(activity) ? ( ) : null} )} ); } function ThreadCard({ entry, collapsed, attachEnabled, brandLabel, onAddToChat, onAddThreadToChat, onToggleCollapsed, }: TimelineEntryCallbacks & { entry: PrThreadEntry; collapsed: boolean; }) { return ( ); } function ReviewCard({ entry, collapsed, collapsedEntryIds, attachEnabled, brandLabel, onAddToChat, onAddThreadToChat, onToggleCollapsed, }: TimelineEntryCallbacks & { entry: PrReviewEntry; collapsed: boolean; collapsedEntryIds: ReadonlySet; }) { const { review, threads } = entry; const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); const hasBody = review.body.trim() !== ""; const handleAddToChat = useCallback(() => onAddToChat(review), [onAddToChat, review]); const handleHeaderPress = useCallback(() => { onToggleCollapsed(entry.id, collapsed); }, [collapsed, entry.id, onToggleCollapsed]); return ( {collapsed && attachEnabled && canAddPullRequestActivityToChat(review) ? ( ) : null} {collapsed ? ( {threads.length} ) : null} {collapsed ? null : ( <> {hasBody ? ( ) : null} {attachEnabled && canAddPullRequestActivityToChat(review) ? ( ) : null} {threads.length > 0 ? ( {threads.map((thread) => ( ))} ) : null} )} ); } function ThreadBlock({ thread, collapsed, attachEnabled, brandLabel, onAddToChat, onAddThreadToChat, onToggleCollapsed, }: { thread: PrThreadEntry; collapsed: boolean; attachEnabled: boolean; brandLabel: string; onAddToChat: (activity: PrPaneActivity) => void; onAddThreadToChat: (thread: PrThreadEntry) => void; onToggleCollapsed: (entryId: string, collapsed: boolean) => void; }) { const { t } = useTranslation(); const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); const handleHeaderPress = useCallback(() => { onToggleCollapsed(thread.id, collapsed); }, [collapsed, onToggleCollapsed, thread.id]); const handleAddThreadToChat = useCallback( () => onAddThreadToChat(thread), [onAddThreadToChat, thread], ); const handleOpenThread = useCallback(() => { void openExternalUrl(thread.comments[0].url); }, [thread.comments]); const [root, ...replies] = thread.comments; return ( {thread.location ? formatPullRequestThreadPath(thread.location) : t("workspace.git.pr.thread.discussion")} {thread.isResolved ? : null} {thread.location?.isOutdated ? : null} {collapsed ? ( {thread.comments.length} ) : null} {renderKebabTriggerIcon} {t("workspace.git.pr.actions.openOn", { brand: brandLabel })} {collapsed ? null : ( <> {replies.length > 0 ? ( {replies.map((reply) => ( ))} ) : null} {attachEnabled ? ( ) : null} )} ); } function threadHeaderPressableStyle({ hovered }: { hovered?: boolean }) { return [styles.threadHeader, Boolean(hovered) && styles.hoverable]; } function threadCommentStyle(contentStyle?: ViewStyle) { return contentStyle ? [styles.threadComment, contentStyle] : styles.threadComment; } function ThreadComment({ comment, attachEnabled, brandLabel, onAddToChat, contentStyle, }: { comment: PrPaneActivity; attachEnabled: boolean; brandLabel: string; onAddToChat: (activity: PrPaneActivity) => void; contentStyle?: ViewStyle; }) { const { actionsVisible, handlePointerEnter, handlePointerLeave, setMenuOpen } = useRevealOnHover(); return ( {comment.body.trim() !== "" ? ( ) : null} ); } const styles = StyleSheet.create((theme) => ({ root: { flex: 1, minHeight: 0, backgroundColor: theme.colors.surfaceSidebar, }, scroll: { flex: 1, minHeight: 0, }, invisible: { opacity: 0, }, hoverable: { backgroundColor: theme.colors.surfaceSidebarHover, }, header: { flexDirection: "column", gap: theme.spacing[2], paddingHorizontal: theme.spacing[4], paddingVertical: theme.spacing[4], }, title: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, lineHeight: 22, }, titleNumber: { color: theme.colors.foregroundMuted, }, metaLine: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], minHeight: 16, }, stateLabelOpen: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.statusSuccess, }, stateLabelDraft: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, stateLabelMerged: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.statusMerged, }, stateLabelClosed: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.statusDanger, }, repoRef: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, flexShrink: 1, marginLeft: theme.spacing[1], }, headerLinkIcon: { marginLeft: theme.spacing[1], }, headerLinkIconHidden: { marginLeft: theme.spacing[1], opacity: 0, }, divider: { height: 1, backgroundColor: theme.colors.border, }, toolbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingRight: theme.spacing[3], paddingLeft: theme.spacing[3], }, toolbarActions: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, viewButton: { gap: theme.spacing[1], minHeight: 24, height: 24, paddingVertical: 0, paddingHorizontal: theme.spacing[1], borderRadius: theme.borderRadius.base, }, refreshIcon: { width: ICON_SIZE.md, height: ICON_SIZE.md, alignItems: "center", justifyContent: "center", }, checkAddButton: { paddingVertical: 0, }, activityToolbar: { flexDirection: "row", alignItems: "center", minHeight: 28, paddingRight: theme.spacing[3], paddingBottom: theme.spacing[2], }, toolbarTrailing: { marginLeft: "auto", }, filterTriggerContent: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, filterHiddenCount: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, eventRow: { marginHorizontal: theme.spacing[3], marginBottom: theme.spacing[2], }, card: { marginHorizontal: theme.spacing[3], marginBottom: theme.spacing[3], borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surfaceSidebar, overflow: "hidden", }, entryHeaderPressable: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], minHeight: 36, }, headerTrailing: { marginLeft: "auto", flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, authorText: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, flexShrink: 1, }, verbGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, verbMuted: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, verbSuccess: { fontSize: theme.fontSize.sm, color: theme.colors.statusSuccess, }, verbDanger: { fontSize: theme.fontSize.sm, color: theme.colors.statusDanger, }, ageText: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted, }, kebabSlot: { width: 22, alignItems: "center", justifyContent: "center", }, kebabSlotHidden: { width: 22, alignItems: "center", justifyContent: "center", opacity: 0, }, kebabButton: { padding: 2, borderRadius: 4, }, kebabButtonHovered: { backgroundColor: theme.colors.surface2, }, cardBody: { paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[3], }, cardFooter: { flexDirection: "row", paddingLeft: 0, paddingRight: theme.spacing[2], paddingBottom: theme.spacing[2], }, nestedThreadsContainer: { paddingTop: theme.spacing[1], paddingBottom: theme.spacing[1], }, nestedThread: { marginHorizontal: theme.spacing[3], marginBottom: theme.spacing[2], borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.md, overflow: "hidden", }, threadHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], minHeight: 36, backgroundColor: theme.colors.surface1, }, threadPath: { fontSize: theme.fontSize.sm, fontFamily: theme.fontFamily.mono, color: theme.colors.foreground, flexShrink: 1, }, threadCount: { flexDirection: "row", alignItems: "center", gap: 3, }, threadComment: { borderTopWidth: 1, borderTopColor: theme.colors.border, paddingBottom: theme.spacing[2], }, threadCommentHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], minHeight: 32, }, threadCommentBody: { paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], }, replyRail: { marginHorizontal: theme.spacing[3], marginTop: theme.spacing[2], }, replyCard: { borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.md, backgroundColor: theme.colors.surface1, marginBottom: theme.spacing[2], overflow: "hidden", }, replyThreadComment: { borderTopWidth: 0, }, avatar: { alignItems: "center", justifyContent: "center", overflow: "hidden", }, avatarText: { fontSize: 10, fontWeight: theme.fontWeight.normal, color: "#fff", }, }));