Improve GitHub panel toolbar and loading states (#1664)
* feat(app): add toolbar and loading states to the GitHub PR panel Adds a toolbar under the PR title with a refresh button (refreshes git and GitHub status + timeline in one tap), an always-visible View Pull Request button, and a Merge control with a method dropdown that is disabled-with-reason when the PR can't be merged yet. The merge control reuses the existing git-actions policy verbatim via a new buildPrPanelMergeActions selector fed into GitActionsSplitButton, so no merge readiness/squash/auto-merge logic is duplicated. Also replaces the empty-state flash with skeletons: a full-panel skeleton while PR status loads and an activity skeleton while the timeline loads, with empty states shown only when loaded-and-empty and an error state with retry. * refactor(app): tidy PR panel skeleton (i18n, shared pulse, index) Addresses review feedback on the PR panel work: - Localize the skeleton's "Checks" label via t() instead of hardcoding. - Hoist the shared SkeletonPulse + useSkeletonPulse into activity-skeleton so pane-skeleton reuses them instead of duplicating the pulse driver. - Export PullRequestPaneError/Skeleton from the module index and import them through it, matching the module's existing public surface. * test(app): remove mock-based PR pane test * fix(app): simplify PR panel toolbar * fix(app): report PR panel retry failures
This commit is contained in:
@@ -15,9 +15,13 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
formatPrTabLabel,
|
||||
PullRequestPane,
|
||||
PullRequestPaneError,
|
||||
PullRequestPaneSkeleton,
|
||||
PullRequestTabIcon,
|
||||
usePrPaneData,
|
||||
} from "@/git/pull-request-panel";
|
||||
import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import type { UsePrPaneDataResult } from "@/git/pull-request-panel/use-data";
|
||||
import {
|
||||
usePanelStore,
|
||||
selectIsFileExplorerOpen,
|
||||
@@ -27,6 +31,7 @@ import {
|
||||
} from "@/stores/panel-store";
|
||||
import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context";
|
||||
import { useSidebarAnimation } from "@/contexts/sidebar-animation-context";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { canCloseRightSidebarGesture } from "@/utils/sidebar-animation-state";
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "@/git/diff-pane";
|
||||
@@ -452,6 +457,7 @@ function ExplorerSidebarContent({
|
||||
}: SidebarContentProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const padding = useWindowControlsPadding("explorerSidebar");
|
||||
const canQueryPullRequest = isGit && Boolean(workspaceRoot);
|
||||
const prPane = usePrPaneData({
|
||||
@@ -461,11 +467,17 @@ function ExplorerSidebarContent({
|
||||
timelineEnabled: activeTab === "pr" && canQueryPullRequest && isOpen,
|
||||
});
|
||||
const hasPullRequest = prPane.prNumber !== null;
|
||||
const showPrTab = hasPullRequest || (activeTab === "pr" && prPane.isLoading);
|
||||
const requestedTab: ExplorerTab =
|
||||
!isGit && (activeTab === "changes" || activeTab === "pr") ? "files" : activeTab;
|
||||
const resolvedTab: ExplorerTab =
|
||||
requestedTab === "pr" && !hasPullRequest ? "changes" : requestedTab;
|
||||
const resolvedTab: ExplorerTab = requestedTab === "pr" && !showPrTab ? "changes" : requestedTab;
|
||||
const prTabLabel = formatPrTabLabel(prPane.prNumber);
|
||||
const refreshGitActions = useCheckoutGitActionsStore((s) => s.refresh);
|
||||
const handlePrRetry = useCallback(() => {
|
||||
refreshGitActions({ serverId, cwd: workspaceRoot }).catch((error) => {
|
||||
toast.error(error instanceof Error ? error.message : t("workspace.git.diff.failedRefresh"));
|
||||
});
|
||||
}, [refreshGitActions, serverId, t, toast, workspaceRoot]);
|
||||
const workspaceAttachmentScopeKey = useMemo(
|
||||
() => buildWorkspaceAttachmentScopeKey({ serverId, workspaceId, cwd: workspaceRoot }),
|
||||
[serverId, workspaceId, workspaceRoot],
|
||||
@@ -498,7 +510,7 @@ function ExplorerSidebarContent({
|
||||
onTabPress={onTabPress}
|
||||
testID="explorer-tab-files"
|
||||
/>
|
||||
{isGit && hasPullRequest && (
|
||||
{isGit && showPrTab && (
|
||||
<ExplorerTabButton
|
||||
tab="pr"
|
||||
active={resolvedTab === "pr"}
|
||||
@@ -542,12 +554,13 @@ function ExplorerSidebarContent({
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
{resolvedTab === "pr" && prPane.data && (
|
||||
<PullRequestPane
|
||||
{resolvedTab === "pr" && (
|
||||
<PrTabContent
|
||||
serverId={serverId}
|
||||
cwd={workspaceRoot}
|
||||
data={prPane.data}
|
||||
prPane={prPane}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
onRetry={handlePrRetry}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
@@ -555,6 +568,38 @@ function ExplorerSidebarContent({
|
||||
);
|
||||
}
|
||||
|
||||
interface PrTabContentProps {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
prPane: UsePrPaneDataResult;
|
||||
workspaceAttachmentScopeKey: string;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function PrTabContent({
|
||||
serverId,
|
||||
cwd,
|
||||
prPane,
|
||||
workspaceAttachmentScopeKey,
|
||||
onRetry,
|
||||
}: PrTabContentProps) {
|
||||
if (prPane.data) {
|
||||
return (
|
||||
<PullRequestPane
|
||||
serverId={serverId}
|
||||
cwd={cwd}
|
||||
data={prPane.data}
|
||||
activityLoading={prPane.activityLoading}
|
||||
workspaceAttachmentScopeKey={workspaceAttachmentScopeKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (prPane.error) {
|
||||
return <PullRequestPaneError onRetry={onRetry} />;
|
||||
}
|
||||
return <PullRequestPaneSkeleton />;
|
||||
}
|
||||
|
||||
// Static styles for Animated.Views — must NOT use Unistyles dynamic theme to
|
||||
// avoid the "Unable to find node on an unmounted component" crash when Unistyles
|
||||
// tries to patch the native node that Reanimated also manages.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
const ROW_KEYS = [0, 1, 2].map((i) => `pr-activity-skeleton-row-${i}`);
|
||||
|
||||
export function useSkeletonPulse(): Animated.Value {
|
||||
const pulse = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(pulse, { toValue: 1, duration: 1000, useNativeDriver: true }),
|
||||
Animated.timing(pulse, { toValue: 0, duration: 1000, useNativeDriver: true }),
|
||||
]),
|
||||
);
|
||||
animation.start();
|
||||
return () => animation.stop();
|
||||
}, [pulse]);
|
||||
return pulse;
|
||||
}
|
||||
|
||||
export function SkeletonPulse({
|
||||
pulse,
|
||||
style,
|
||||
}: {
|
||||
pulse: Animated.Value;
|
||||
style: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
const opacity = pulse.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0.4, 0.8],
|
||||
});
|
||||
const pulseStyle = useMemo(() => [style, { opacity }], [style, opacity]);
|
||||
return <Animated.View style={pulseStyle} />;
|
||||
}
|
||||
|
||||
export function PrActivitySkeleton() {
|
||||
const pulse = useSkeletonPulse();
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="pr-pane-activity-skeleton">
|
||||
{ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.row}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.avatar} />
|
||||
<View style={styles.lines}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.lineWide} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.lineNarrow} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
avatar: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
lines: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
lineWide: {
|
||||
width: "70%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
lineNarrow: {
|
||||
width: "45%",
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -1,4 +1,6 @@
|
||||
export { PullRequestPane } from "./pane";
|
||||
export { PullRequestPaneError } from "./pane-error";
|
||||
export { PullRequestPaneSkeleton } from "./pane-skeleton";
|
||||
export { PullRequestTabIcon } from "./tab-icon";
|
||||
export { formatPrTabLabel } from "./tab-label";
|
||||
export { prPaneTimelineQueryKind } from "./query-keys";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { RotateCw } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function PullRequestPaneError({
|
||||
onRetry,
|
||||
message,
|
||||
}: {
|
||||
onRetry: () => void;
|
||||
message?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane-error">
|
||||
<Text style={styles.message}>{message ?? t("workspace.git.diff.failedRefresh")}</Text>
|
||||
<Button variant="ghost" size="xs" leftIcon={RotateCw} onPress={onRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
message: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PrActivitySkeleton, SkeletonPulse, useSkeletonPulse } from "./activity-skeleton";
|
||||
|
||||
const CHECK_ROW_KEYS = [0, 1, 2].map((i) => `pr-pane-skeleton-check-${i}`);
|
||||
|
||||
export function PullRequestPaneSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane-skeleton">
|
||||
<View style={styles.header}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.title} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.subtitle} />
|
||||
</View>
|
||||
|
||||
<View style={styles.toolbar}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.toolbarButton} />
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>{t("workspace.git.pr.sections.checks")}</Text>
|
||||
<View style={styles.checks}>
|
||||
{CHECK_ROW_KEYS.map((key) => (
|
||||
<View key={key} style={styles.checkRow}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.checkDot} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.checkName} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<PrActivitySkeleton />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
root: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
backgroundColor: theme.colors.surfaceSidebar,
|
||||
},
|
||||
header: {
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[4],
|
||||
},
|
||||
title: {
|
||||
width: "75%",
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
subtitle: {
|
||||
width: "40%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
toolbar: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
},
|
||||
toolbarButton: {
|
||||
width: 96,
|
||||
height: 24,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
section: {
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[2],
|
||||
},
|
||||
checks: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
checkRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
minHeight: 32,
|
||||
},
|
||||
checkDot: {
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
checkName: {
|
||||
width: "60%",
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
}));
|
||||
@@ -25,10 +25,13 @@ import {
|
||||
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,
|
||||
@@ -42,9 +45,12 @@ 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 type { Theme } from "@/styles/theme";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { PrActivitySkeleton } from "./activity-skeleton";
|
||||
import {
|
||||
collapseActivity,
|
||||
expandActivity,
|
||||
@@ -85,6 +91,8 @@ 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 foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
@@ -138,6 +146,13 @@ function kebabTriggerStyle({
|
||||
return [styles.kebabButton, hovered && styles.kebabButtonHovered];
|
||||
}
|
||||
|
||||
function refreshButtonStyle({
|
||||
hovered = false,
|
||||
pressed = false,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.refreshButton, (hovered || pressed) && styles.refreshButtonHovered];
|
||||
}
|
||||
|
||||
function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) {
|
||||
return (
|
||||
<ThemedMoreHorizontal
|
||||
@@ -176,13 +191,17 @@ export function PullRequestPane({
|
||||
serverId,
|
||||
cwd,
|
||||
data,
|
||||
activityLoading,
|
||||
workspaceAttachmentScopeKey,
|
||||
}: {
|
||||
serverId: string;
|
||||
cwd: string;
|
||||
data: PrPaneData;
|
||||
activityLoading: boolean;
|
||||
workspaceAttachmentScopeKey?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const daemonClient = useHostRuntimeClient(serverId);
|
||||
const canFetchGitHubCheckDetails = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.githubCheckDetails === true,
|
||||
@@ -199,6 +218,24 @@ export function PullRequestPane({
|
||||
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);
|
||||
}, []);
|
||||
@@ -395,6 +432,47 @@ export function PullRequestPane({
|
||||
return (
|
||||
<View style={styles.root} testID="pr-pane">
|
||||
<ScrollView style={styles.scroll} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.toolbar} testID="pr-pane-toolbar">
|
||||
<View style={styles.toolbarActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
leftIcon={ExternalLink}
|
||||
onPress={handleOpenPrUrl}
|
||||
style={styles.viewButton}
|
||||
testID="pr-pane-view-pr"
|
||||
>
|
||||
{t("workspace.git.pr.actions.viewPullRequest")}
|
||||
</Button>
|
||||
</View>
|
||||
{refreshSupported ? (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
isRefreshing
|
||||
? t("workspace.git.diff.refreshing")
|
||||
: t("workspace.git.diff.refreshState")
|
||||
}
|
||||
testID="pr-pane-refresh"
|
||||
style={refreshButtonStyle}
|
||||
hitSlop={8}
|
||||
onPress={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<View style={styles.refreshIcon}>
|
||||
{isRefreshing ? (
|
||||
<ThemedLoadingSpinner
|
||||
size={ICON_SIZE.sm}
|
||||
uniProps={foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
)}
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<Pressable onPress={handleOpenPrUrl} style={styles.header}>
|
||||
{({ hovered }) => (
|
||||
<>
|
||||
@@ -420,8 +498,6 @@ export function PullRequestPane({
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<Section
|
||||
title="Checks"
|
||||
open={checksOpen}
|
||||
@@ -488,27 +564,30 @@ export function PullRequestPane({
|
||||
size="xs"
|
||||
leftIcon={MessageSquarePlus}
|
||||
onPress={handleAddAllToChat}
|
||||
disabled={activityLoading}
|
||||
>
|
||||
Add all to chat
|
||||
</Button>
|
||||
</View>
|
||||
) : null}
|
||||
{visibleEntries.length === 0 ? (
|
||||
{activityLoading ? <PrActivitySkeleton /> : null}
|
||||
{!activityLoading && visibleEntries.length === 0 ? (
|
||||
<Text style={styles.emptyText}>No activity yet</Text>
|
||||
) : (
|
||||
visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
) : null}
|
||||
{!activityLoading
|
||||
? visibleEntries.map(({ entry, collapsed }) => (
|
||||
<TimelineEntryCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
collapsed={collapsed}
|
||||
collapsedEntryIds={collapsedEntryIds}
|
||||
attachEnabled={attachEnabled}
|
||||
onAddToChat={handleAddActivityToChat}
|
||||
onAddThreadToChat={handleAddThreadToChat}
|
||||
onToggleCollapsed={handleToggleEntryCollapsed}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
@@ -1236,6 +1315,48 @@ const styles = StyleSheet.create((theme) => ({
|
||||
height: 1,
|
||||
backgroundColor: theme.colors.border,
|
||||
},
|
||||
toolbar: {
|
||||
height: WORKSPACE_SECONDARY_HEADER_HEIGHT,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.colors.border,
|
||||
paddingTop: theme.spacing[2],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingBottom: theme.spacing[2],
|
||||
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,
|
||||
},
|
||||
refreshButton: {
|
||||
marginLeft: "auto",
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
refreshButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
refreshIcon: {
|
||||
width: ICON_SIZE.md,
|
||||
height: ICON_SIZE.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -435,6 +435,37 @@ describe("selectPrPaneState", () => {
|
||||
expect(state.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reports activityLoading while the timeline is pending its first response", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: true,
|
||||
timelineIsLoading: true,
|
||||
});
|
||||
expect(state.activityLoading).toBe(true);
|
||||
});
|
||||
|
||||
it("does not report activityLoading when no timeline fetch was scheduled", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: false,
|
||||
timelineIsLoading: true,
|
||||
});
|
||||
expect(state.activityLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("does not report activityLoading once the timeline payload resolves", () => {
|
||||
const state = selectPrPaneState({
|
||||
...baseSelectInput,
|
||||
status: prStatus(),
|
||||
shouldFetchTimeline: true,
|
||||
timelineIsLoading: false,
|
||||
timelinePayload: timelinePayload(),
|
||||
});
|
||||
expect(state.activityLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("reports refreshing during background revalidation of either query", () => {
|
||||
expect(
|
||||
selectPrPaneState({
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface UsePrPaneDataResult {
|
||||
data: PrPaneData | null;
|
||||
prNumber: number | null;
|
||||
isLoading: boolean;
|
||||
activityLoading: boolean;
|
||||
isRefreshing: boolean;
|
||||
error: Error | null;
|
||||
githubFeaturesEnabled: boolean;
|
||||
@@ -167,13 +168,14 @@ export function selectPrPaneState(input: SelectPrPaneStateInput): UsePrPaneDataR
|
||||
: mapPrPaneData(input.status, input.timelinePayload);
|
||||
const statusRefreshing = input.statusIsFetching && !input.statusIsLoading;
|
||||
const timelineRefreshing = input.timelineIsFetching && !input.timelineIsLoading;
|
||||
const timelinePending =
|
||||
input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined;
|
||||
|
||||
return {
|
||||
data,
|
||||
prNumber: identity.prNumber,
|
||||
isLoading:
|
||||
input.statusIsLoading ||
|
||||
(input.shouldFetchTimeline && input.timelineIsLoading && input.timelinePayload === undefined),
|
||||
isLoading: input.statusIsLoading || timelinePending,
|
||||
activityLoading: timelinePending,
|
||||
isRefreshing: statusRefreshing || timelineRefreshing,
|
||||
error: firstNonSuppressedError({
|
||||
statusPayloadError: input.statusPayloadError,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { type CheckoutPrStatusPayload, useCheckoutPrStatusQuery } from "@/git/us
|
||||
import {
|
||||
buildGitActions,
|
||||
narrowPullRequestState,
|
||||
type BuildGitActionsInput,
|
||||
type GitAction,
|
||||
type GitActions,
|
||||
} from "@/git/policy";
|
||||
@@ -568,8 +569,8 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
}, [prStatus?.url, handleCreatePr]);
|
||||
|
||||
// Build actions
|
||||
const gitActions: GitActions = useMemo(() => {
|
||||
const actions = buildGitActions({
|
||||
const gitActionsInput = useMemo<BuildGitActionsInput>(
|
||||
() => ({
|
||||
isGit,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
@@ -684,61 +685,66 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
handler: handleArchiveWorktree,
|
||||
},
|
||||
},
|
||||
});
|
||||
return translateGitActions(actions, { baseRefLabel, hasPullRequest, t });
|
||||
}, [
|
||||
t,
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
prStatus?.state,
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
actionsDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
]);
|
||||
}),
|
||||
[
|
||||
isGit,
|
||||
hasRemote,
|
||||
hasPullRequest,
|
||||
prStatus?.url,
|
||||
prStatus?.state,
|
||||
prStatus?.isDraft,
|
||||
prStatus?.isMerged,
|
||||
prStatus?.mergeable,
|
||||
prStatus?.github,
|
||||
aheadCount,
|
||||
behindBaseCount,
|
||||
isPaseoOwnedWorktree,
|
||||
isOnBaseBranch,
|
||||
githubFeaturesEnabled,
|
||||
githubAutoMergeActionsEnabled,
|
||||
hasUncommittedChanges,
|
||||
aheadOfOrigin,
|
||||
behindOfOrigin,
|
||||
shipDefault,
|
||||
baseRefLabel,
|
||||
shouldPromoteArchive,
|
||||
actionsDisabled,
|
||||
commitStatus,
|
||||
pullStatus,
|
||||
pushStatus,
|
||||
pullAndPushStatus,
|
||||
prCreateStatus,
|
||||
mergePrStatuses.squash,
|
||||
mergePrStatuses.merge,
|
||||
mergePrStatuses.rebase,
|
||||
enablePrAutoMergeStatuses.squash,
|
||||
enablePrAutoMergeStatuses.merge,
|
||||
enablePrAutoMergeStatuses.rebase,
|
||||
disablePrAutoMergeStatus,
|
||||
mergeStatus,
|
||||
mergeFromBaseStatus,
|
||||
archiveStatus,
|
||||
handleCommit,
|
||||
handlePull,
|
||||
handlePush,
|
||||
handlePullAndPush,
|
||||
handlePrAction,
|
||||
handleMergePr,
|
||||
handleEnablePrAutoMerge,
|
||||
handleDisablePrAutoMerge,
|
||||
handleMergeBranch,
|
||||
handleMergeFromBase,
|
||||
handleArchiveWorktree,
|
||||
icons,
|
||||
baseRef,
|
||||
],
|
||||
);
|
||||
|
||||
const gitActions: GitActions = useMemo(
|
||||
() =>
|
||||
translateGitActions(buildGitActions(gitActionsInput), { baseRefLabel, hasPullRequest, t }),
|
||||
[gitActionsInput, baseRefLabel, hasPullRequest, t],
|
||||
);
|
||||
|
||||
return { gitActions, branchLabel, isGit };
|
||||
}
|
||||
|
||||
@@ -243,6 +243,7 @@ describe("translation resources", () => {
|
||||
expect(en.workspace.git.actions.commit.label).toBe("Commit");
|
||||
expect(en.workspace.git.diff.binaryFile).toBe("Binary file");
|
||||
expect(en.workspace.git.pr.sections.checks).toBe("Checks");
|
||||
expect(en.workspace.git.pr.actions.viewPullRequest).toBe("View");
|
||||
expect(en.review.comment.placeholder).toBe("Leave a comment");
|
||||
});
|
||||
|
||||
|
||||
@@ -717,6 +717,9 @@ export const ar: TranslationResources = {
|
||||
failedOpen: "فشل في فتح مساحة العمل",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "عرض",
|
||||
},
|
||||
sections: {
|
||||
checks: "الشيكات",
|
||||
reviews: "التعليقات",
|
||||
|
||||
@@ -724,6 +724,9 @@ export const en = {
|
||||
failedOpen: "Failed to open workspace",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "View",
|
||||
},
|
||||
sections: {
|
||||
checks: "Checks",
|
||||
reviews: "Reviews",
|
||||
|
||||
@@ -744,6 +744,9 @@ export const es: TranslationResources = {
|
||||
failedOpen: "No se pudo abrir el espacio de trabajo",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Ver",
|
||||
},
|
||||
sections: {
|
||||
checks: "cheques",
|
||||
reviews: "Reseñas",
|
||||
|
||||
@@ -743,6 +743,9 @@ export const fr: TranslationResources = {
|
||||
failedOpen: "Échec de l'ouverture de l'espace de travail",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Voir",
|
||||
},
|
||||
sections: {
|
||||
checks: "Chèques",
|
||||
reviews: "Avis",
|
||||
|
||||
@@ -736,6 +736,9 @@ export const ru: TranslationResources = {
|
||||
failedOpen: "Не удалось открыть рабочую область",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "Открыть",
|
||||
},
|
||||
sections: {
|
||||
checks: "Чеки",
|
||||
reviews: "Отзывы",
|
||||
|
||||
@@ -711,6 +711,9 @@ export const zhCN: TranslationResources = {
|
||||
failedOpen: "打开 workspace 失败",
|
||||
},
|
||||
pr: {
|
||||
actions: {
|
||||
viewPullRequest: "查看",
|
||||
},
|
||||
sections: {
|
||||
checks: "Checks",
|
||||
reviews: "Reviews",
|
||||
|
||||
Reference in New Issue
Block a user