* feat: tabbed right sidebar, context panel, floating diff comments * fix: auto-close left sidebar when context panel opens - Increase default context panel width from 520 to 600 pixels - Increase sidebar minimum width from 200 to 300 pixels - Replace collapsible component with custom button in diff view * refactoring: rework sidebars, tabs, and file tree layout - Rewrite AnimatedTabs as segment-style with sliding indicator - Upgrade SidebarFilesTree to match FilesView features (context menus, git status, file icons, CRUD dialogs, fuzzy search ranking) - Restructure FilesView header: tabs row + actions row, remove breadcrumbs - Show relative path in context panel header, track active tab - Allow left sidebar to stay open alongside context panel - Hide diff/files tabs from header on desktop (mobile-only) - Move chevron after group name in session sidebar - Compact tab heights in right sidebar and git view - Size PreviewToggleButton to match other action buttons - Remove directory loading spinner from folder icons * feat: add project icon and color customization - Enable users to assign custom icons to projects - Allow users to choose accent colors for projects - Stabilize repo status UI during project switching * feat: add scroll fade indicators to editor tabs * style: reduce spacing and icon sizes in header * style: adjust tab component padding from uniform to vertical-horizontal * feat: Add session state indicators to project tabs * feat: Enhance session status handling and improve UI responsiveness * fix: preserve upstream tracking on branch rename * fix: improve initial remote selection for pull requests - Uses saved remote name from previous session when available - Selects remote based on tracking branch when possible - Falls back to origin or first available remote * perf(diff): faster highlight, stable stacked scroll - split/unified Pierre worker pools; prefer shiki-wasm - align diff CSS line-height; disable scroll anchoring; drop WebKit compositing hacks - harden stacked pin/align (cancel on user scroll/input); prevent overscroll - make overlay scrollbar MutationObserver optional; disable for diff container * feat: handle binary files in diff view * fix: adjust project tabs layout and drag regions * style: update drag overlay visual styling * feat: enable number keys to switch projects in the sidebar * fix: recognize octet-stream as text-based MIME type * feat: add keyboard navigation to context panel * feat: add session pinning to sidebar - Pin important sessions to keep them at the top - Pinned sessions persist across browser sessions * refactor: move context usage display from chat input to header
1988 lines
77 KiB
TypeScript
1988 lines
77 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
RiChat4Line,
|
|
RiCheckLine,
|
|
RiCheckboxCircleLine,
|
|
RiAiGenerate2,
|
|
RiArrowDownSLine,
|
|
RiArrowRightSLine,
|
|
RiCheckboxBlankLine,
|
|
RiCheckboxLine,
|
|
RiCloseLine,
|
|
RiEditLine,
|
|
RiErrorWarningLine,
|
|
RiExternalLinkLine,
|
|
RiGitClosePullRequestLine,
|
|
RiGitMergeLine,
|
|
RiGitPrDraftLine,
|
|
RiGitPullRequestLine,
|
|
RiInformationLine,
|
|
RiLoader4Line,
|
|
} from '@remixicon/react';
|
|
import { toast } from '@/components/ui';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { generatePullRequestDescription } from '@/lib/gitApi';
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
|
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useMessageStore } from '@/stores/messageStore';
|
|
import { useSessionStore } from '@/stores/useSessionStore';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import type {
|
|
GitHubPullRequest,
|
|
GitHubCheckRun,
|
|
GitHubPullRequestContextResult,
|
|
GitHubPullRequestStatus,
|
|
GitRemote,
|
|
} from '@/lib/api/types';
|
|
|
|
type MergeMethod = 'merge' | 'squash' | 'rebase';
|
|
|
|
const PR_REVALIDATE_TTL_MS = 90_000;
|
|
const PR_REVALIDATE_INTERVAL_MS = 30_000;
|
|
const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000;
|
|
|
|
const statusColor = (state: string | undefined | null): string => {
|
|
switch (state) {
|
|
case 'success':
|
|
return 'bg-[color:var(--status-success)]';
|
|
case 'failure':
|
|
return 'bg-[color:var(--status-error)]';
|
|
case 'pending':
|
|
return 'bg-[color:var(--status-warning)]';
|
|
default:
|
|
return 'bg-muted-foreground/40';
|
|
}
|
|
};
|
|
|
|
const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'open' | 'blocked' | 'merged' | 'closed' | null => {
|
|
const pr = status?.pr;
|
|
if (!pr) {
|
|
return null;
|
|
}
|
|
if (pr.state === 'merged') {
|
|
return 'merged';
|
|
}
|
|
if (pr.state === 'closed') {
|
|
return 'closed';
|
|
}
|
|
if (pr.draft) {
|
|
return 'draft';
|
|
}
|
|
const checksFailed = status?.checks?.state === 'failure';
|
|
const notMergeable = status?.canMerge === false || pr.mergeable === false;
|
|
if (checksFailed || notMergeable) {
|
|
return 'blocked';
|
|
}
|
|
return 'open';
|
|
};
|
|
|
|
const branchToTitle = (branch: string): string => {
|
|
return branch
|
|
.replace(/^refs\/heads\//, '')
|
|
.replace(/[-_]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
};
|
|
|
|
const normalizeBranchRef = (value: string): string => {
|
|
let normalized = value.trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
if (normalized.startsWith('refs/heads/')) {
|
|
normalized = normalized.slice('refs/heads/'.length);
|
|
}
|
|
if (normalized.startsWith('heads/')) {
|
|
normalized = normalized.slice('heads/'.length);
|
|
}
|
|
if (normalized.startsWith('remotes/')) {
|
|
normalized = normalized.slice('remotes/'.length);
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
const remoteBranchToName = (value: string, remoteName: string | null): string => {
|
|
const normalized = normalizeBranchRef(value);
|
|
if (!normalized || normalized.includes('->')) {
|
|
return '';
|
|
}
|
|
|
|
if (remoteName) {
|
|
const prefix = `${remoteName}/`;
|
|
if (normalized.startsWith(prefix)) {
|
|
return normalized.slice(prefix.length).trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
const slashIndex = normalized.indexOf('/');
|
|
if (slashIndex > 0) {
|
|
return normalized.slice(slashIndex + 1).trim();
|
|
}
|
|
return normalized;
|
|
};
|
|
|
|
const getPullRequestSnapshotKey = (directory: string, branch: string): string => `${directory}::${branch}`;
|
|
|
|
type PullRequestDraftSnapshot = {
|
|
title: string;
|
|
body: string;
|
|
draft: boolean;
|
|
additionalContext: string;
|
|
targetBaseBranch?: string;
|
|
selectedRemoteName?: string;
|
|
};
|
|
|
|
const getTrackingRemoteName = (trackingBranch: string | null | undefined): string => {
|
|
const normalized = String(trackingBranch || '').trim();
|
|
if (!normalized) {
|
|
return '';
|
|
}
|
|
|
|
const slashIndex = normalized.indexOf('/');
|
|
if (slashIndex <= 0) {
|
|
return '';
|
|
}
|
|
|
|
return normalized.slice(0, slashIndex).trim();
|
|
};
|
|
|
|
const pickInitialPrRemote = (
|
|
remotes: GitRemote[],
|
|
options: { selectedRemoteName?: string; trackingBranch?: string }
|
|
): GitRemote | null => {
|
|
if (remotes.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const selectedRemoteName = String(options.selectedRemoteName || '').trim();
|
|
if (selectedRemoteName) {
|
|
const fromSnapshot = remotes.find((remote) => remote.name === selectedRemoteName);
|
|
if (fromSnapshot) {
|
|
return fromSnapshot;
|
|
}
|
|
}
|
|
|
|
const trackingRemoteName = getTrackingRemoteName(options.trackingBranch);
|
|
if (trackingRemoteName) {
|
|
const maybeUpstream =
|
|
trackingRemoteName === 'origin'
|
|
? remotes.find((remote) => remote.name === 'upstream')
|
|
: null;
|
|
if (maybeUpstream) {
|
|
return maybeUpstream;
|
|
}
|
|
|
|
const fromTracking = remotes.find((remote) => remote.name === trackingRemoteName);
|
|
if (fromTracking) {
|
|
return fromTracking;
|
|
}
|
|
}
|
|
|
|
const originRemote = remotes.find((remote) => remote.name === 'origin');
|
|
if (originRemote) {
|
|
return originRemote;
|
|
}
|
|
|
|
return remotes[0] ?? null;
|
|
};
|
|
|
|
type TimelineCommentItem = {
|
|
id: string;
|
|
body: string;
|
|
authorName: string;
|
|
authorLogin: string | null;
|
|
avatarUrl: string | null;
|
|
createdAt?: string;
|
|
context: string;
|
|
path: string | null;
|
|
line: number | null;
|
|
};
|
|
|
|
type ChatDispatchTarget = {
|
|
sessionId: string;
|
|
providerID: string;
|
|
modelID: string;
|
|
currentAgentName: string | null;
|
|
currentVariant: string | null;
|
|
};
|
|
|
|
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
|
const pullRequestStatusSnapshots = new Map<string, GitHubPullRequestStatus>();
|
|
|
|
type TauriShell = {
|
|
shell?: {
|
|
open?: (url: string) => Promise<unknown>;
|
|
};
|
|
};
|
|
|
|
const openExternal = async (url: string) => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
|
|
if (tauri?.shell?.open) {
|
|
try {
|
|
await tauri.shell.open(url);
|
|
return;
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
try {
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
export const PullRequestSection: React.FC<{
|
|
directory: string;
|
|
branch: string;
|
|
baseBranch: string;
|
|
trackingBranch?: string;
|
|
remotes?: GitRemote[];
|
|
remoteBranches?: string[];
|
|
variant?: 'framed' | 'plain';
|
|
onGeneratedDescription?: () => void;
|
|
}> = ({ directory, branch, baseBranch, trackingBranch, remotes = [], remoteBranches = [], variant = 'framed', onGeneratedDescription }) => {
|
|
const { github } = useRuntimeAPIs();
|
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
|
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
|
|
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
|
const currentSessionId = useSessionStore((state) => state.currentSessionId);
|
|
const { isMobile, hasTouchInput } = useDeviceInfo();
|
|
|
|
const openGitHubSettings = React.useCallback(() => {
|
|
setSidebarSection('settings');
|
|
setSettingsDialogOpen(true);
|
|
}, [setSettingsDialogOpen, setSidebarSection]);
|
|
|
|
const snapshotKey = React.useMemo(() => getPullRequestSnapshotKey(directory, branch), [directory, branch]);
|
|
const initialSnapshot = React.useMemo(
|
|
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
|
|
[snapshotKey]
|
|
);
|
|
const initialStatusSnapshot = React.useMemo(
|
|
() => pullRequestStatusSnapshots.get(snapshotKey) ?? null,
|
|
[snapshotKey]
|
|
);
|
|
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(() => initialStatusSnapshot);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [isInitialStatusResolved, setIsInitialStatusResolved] = React.useState(() => Boolean(initialStatusSnapshot));
|
|
|
|
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
|
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
|
const [draft, setDraft] = React.useState(() => initialSnapshot?.draft ?? false);
|
|
const [additionalContext, setAdditionalContext] = React.useState(() => initialSnapshot?.additionalContext ?? '');
|
|
const [targetBaseBranch, setTargetBaseBranch] = React.useState(() => {
|
|
const fromSnapshot = typeof initialSnapshot?.targetBaseBranch === 'string'
|
|
? normalizeBranchRef(initialSnapshot.targetBaseBranch)
|
|
: '';
|
|
if (fromSnapshot) {
|
|
return fromSnapshot;
|
|
}
|
|
return normalizeBranchRef(baseBranch);
|
|
});
|
|
const [mergeMethod, setMergeMethod] = React.useState<MergeMethod>('squash');
|
|
|
|
const [isGenerating, setIsGenerating] = React.useState(false);
|
|
const [isCreating, setIsCreating] = React.useState(false);
|
|
const [isUpdating, setIsUpdating] = React.useState(false);
|
|
const [isMerging, setIsMerging] = React.useState(false);
|
|
const [isMarkingReady, setIsMarkingReady] = React.useState(false);
|
|
const [isEditingPr, setIsEditingPr] = React.useState(false);
|
|
const [hydratingPrBodyKey, setHydratingPrBodyKey] = React.useState<string | null>(null);
|
|
const [editTitle, setEditTitle] = React.useState('');
|
|
const [editBody, setEditBody] = React.useState('');
|
|
|
|
const [isContextOpen, setIsContextOpen] = React.useState(false);
|
|
const [isContextSheetOpen, setIsContextSheetOpen] = React.useState(false);
|
|
const [selectedRemote, setSelectedRemote] = React.useState<GitRemote | null>(() =>
|
|
pickInitialPrRemote(remotes, {
|
|
selectedRemoteName: initialSnapshot?.selectedRemoteName,
|
|
trackingBranch,
|
|
})
|
|
);
|
|
|
|
const availableBaseBranches = React.useMemo(() => {
|
|
const selectedRemoteName = selectedRemote?.name?.trim() || null;
|
|
const unique = new Set<string>();
|
|
|
|
for (const remoteBranch of remoteBranches) {
|
|
const branchName = remoteBranchToName(remoteBranch, selectedRemoteName);
|
|
if (!branchName || branchName === 'HEAD') {
|
|
continue;
|
|
}
|
|
unique.add(branchName);
|
|
}
|
|
|
|
const defaultBase = normalizeBranchRef(baseBranch);
|
|
if (defaultBase && defaultBase !== 'HEAD') {
|
|
unique.add(defaultBase);
|
|
}
|
|
|
|
const currentTarget = normalizeBranchRef(targetBaseBranch);
|
|
if (currentTarget && currentTarget !== 'HEAD') {
|
|
unique.add(currentTarget);
|
|
}
|
|
|
|
return Array.from(unique).sort((a, b) => a.localeCompare(b));
|
|
}, [baseBranch, remoteBranches, selectedRemote?.name, targetBaseBranch]);
|
|
|
|
const hasMultipleRemotes = remotes.length > 1;
|
|
|
|
// Update selected remote when remotes change
|
|
React.useEffect(() => {
|
|
if (remotes.length === 0) {
|
|
if (selectedRemote) {
|
|
setSelectedRemote(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (!selectedRemote || !remotes.some((remote) => remote.name === selectedRemote.name)) {
|
|
setSelectedRemote(
|
|
pickInitialPrRemote(remotes, {
|
|
selectedRemoteName: initialSnapshot?.selectedRemoteName,
|
|
trackingBranch,
|
|
})
|
|
);
|
|
}
|
|
}, [initialSnapshot?.selectedRemoteName, remotes, selectedRemote, trackingBranch]);
|
|
|
|
React.useEffect(() => {
|
|
const normalizedBase = normalizeBranchRef(baseBranch);
|
|
if (!targetBaseBranch && normalizedBase) {
|
|
setTargetBaseBranch(normalizedBase);
|
|
return;
|
|
}
|
|
|
|
if (availableBaseBranches.length === 0) {
|
|
return;
|
|
}
|
|
|
|
if (!availableBaseBranches.includes(targetBaseBranch)) {
|
|
const fallback = availableBaseBranches.includes(normalizedBase)
|
|
? normalizedBase
|
|
: availableBaseBranches[0];
|
|
if (fallback) {
|
|
setTargetBaseBranch(fallback);
|
|
}
|
|
}
|
|
}, [availableBaseBranches, baseBranch, targetBaseBranch]);
|
|
|
|
const [checksDialogOpen, setChecksDialogOpen] = React.useState(false);
|
|
const [checkDetails, setCheckDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
|
const [isLoadingCheckDetails, setIsLoadingCheckDetails] = React.useState(false);
|
|
const [expandedCheckStepKeys, setExpandedCheckStepKeys] = React.useState<Set<string>>(new Set());
|
|
const [commentsDialogOpen, setCommentsDialogOpen] = React.useState(false);
|
|
const [commentsDetails, setCommentsDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
|
const [isLoadingCommentsDetails, setIsLoadingCommentsDetails] = React.useState(false);
|
|
|
|
const isRefreshInFlightRef = React.useRef(false);
|
|
const lastRefreshAtRef = React.useRef(0);
|
|
const lastDiscoveryPollAtRef = React.useRef(0);
|
|
const statusRef = React.useRef<GitHubPullRequestStatus | null>(null);
|
|
const attemptedBodyHydrationRef = React.useRef<Set<string>>(new Set());
|
|
const lastSyncedPrNumberRef = React.useRef<number | null>(null);
|
|
|
|
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
|
|
|
|
const pr = status?.pr ?? null;
|
|
const currentPrBodyHydrationKey = pr ? `${directory}#${pr.number}` : null;
|
|
const isHydratingCurrentPrBody = Boolean(
|
|
currentPrBodyHydrationKey && hydratingPrBodyKey === currentPrBodyHydrationKey,
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!github?.prContext || !pr) {
|
|
return;
|
|
}
|
|
|
|
if (typeof pr.body === 'string' && pr.body.length > 0) {
|
|
return;
|
|
}
|
|
|
|
const hydrationKey = `${directory}#${pr.number}`;
|
|
if (attemptedBodyHydrationRef.current.has(hydrationKey)) {
|
|
return;
|
|
}
|
|
attemptedBodyHydrationRef.current.add(hydrationKey);
|
|
setHydratingPrBodyKey(hydrationKey);
|
|
|
|
let cancelled = false;
|
|
void github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false })
|
|
.then((ctx) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const ctxPr = ctx?.pr;
|
|
if (!ctxPr) {
|
|
return;
|
|
}
|
|
setStatus((prev) => {
|
|
if (!prev?.pr || prev.pr.number !== pr.number) {
|
|
return prev;
|
|
}
|
|
return {
|
|
...prev,
|
|
pr: {
|
|
...prev.pr,
|
|
body: ctxPr.body || '',
|
|
},
|
|
};
|
|
});
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
setHydratingPrBodyKey((prev) => (prev === hydrationKey ? null : prev));
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [directory, github, pr]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pr) {
|
|
setIsEditingPr(false);
|
|
setEditTitle('');
|
|
setEditBody('');
|
|
lastSyncedPrNumberRef.current = null;
|
|
return;
|
|
}
|
|
|
|
const numberChanged =
|
|
lastSyncedPrNumberRef.current !== null && lastSyncedPrNumberRef.current !== pr.number;
|
|
|
|
if (numberChanged) {
|
|
setIsEditingPr(false);
|
|
}
|
|
|
|
if (!isEditingPr || numberChanged) {
|
|
setEditTitle(pr.title || '');
|
|
setEditBody(pr.body || '');
|
|
}
|
|
|
|
lastSyncedPrNumberRef.current = pr.number;
|
|
}, [isEditingPr, pr]);
|
|
|
|
const openChecksDialog = React.useCallback(async () => {
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!pr) return;
|
|
|
|
setChecksDialogOpen(true);
|
|
setExpandedCheckStepKeys(new Set());
|
|
setIsLoadingCheckDetails(true);
|
|
try {
|
|
const ctx = await github.prContext(directory, pr.number, {
|
|
includeDiff: false,
|
|
includeCheckDetails: true,
|
|
});
|
|
setCheckDetails(ctx);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load check details', { description: message });
|
|
} finally {
|
|
setIsLoadingCheckDetails(false);
|
|
}
|
|
}, [directory, github, pr]);
|
|
|
|
const openCommentsDialog = React.useCallback(async () => {
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!pr) return;
|
|
|
|
setCommentsDialogOpen(true);
|
|
setIsLoadingCommentsDetails(true);
|
|
try {
|
|
const ctx = await github.prContext(directory, pr.number, {
|
|
includeDiff: false,
|
|
includeCheckDetails: false,
|
|
});
|
|
setCommentsDetails(ctx);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load comments', { description: message });
|
|
} finally {
|
|
setIsLoadingCommentsDetails(false);
|
|
}
|
|
}, [directory, github, pr]);
|
|
|
|
const formatTimestamp = React.useCallback((value?: string) => {
|
|
if (!value) return '';
|
|
const ts = Date.parse(value);
|
|
if (!Number.isFinite(ts)) {
|
|
return value;
|
|
}
|
|
return new Date(ts).toLocaleString();
|
|
}, []);
|
|
|
|
const connectedGitHubLogin = React.useMemo(() => {
|
|
const login = githubAuthStatus?.user?.login;
|
|
return typeof login === 'string' ? login.trim() : '';
|
|
}, [githubAuthStatus]);
|
|
|
|
const selfMentionHighlightClass = React.useMemo(() => {
|
|
return "[&_a[href*='oc-self-mention=1']]:!text-[var(--primary-base)] [&_a[href*='oc-self-mention=1']]:font-semibold [&_a[href*='oc-self-mention=1']]:!no-underline [&_a[href*='oc-self-mention=1']:hover]:!text-[var(--primary-hover)]";
|
|
}, []);
|
|
|
|
const linkifyMentionsMarkdown = React.useCallback((content: string) => {
|
|
const selfLoginLower = connectedGitHubLogin.toLowerCase();
|
|
const mentionRegex = /(^|[^\w`])@([a-zA-Z0-9](?:[a-zA-Z0-9-]{0,38}))/g;
|
|
return content.replace(mentionRegex, (_match, prefix: string, username: string) => {
|
|
const mention = `@${username}`;
|
|
const usernameLower = username.toLowerCase();
|
|
const selfTag = selfLoginLower && usernameLower === selfLoginLower ? '?oc-self-mention=1' : '';
|
|
return `${prefix}[${mention}](https://github.com/${usernameLower}${selfTag})`;
|
|
});
|
|
}, [connectedGitHubLogin]);
|
|
|
|
const timelineComments = React.useMemo<TimelineCommentItem[]>(() => {
|
|
const issue = (commentsDetails?.issueComments ?? []).map((comment) => ({
|
|
id: `issue-${comment.id}`,
|
|
body: comment.body || '',
|
|
authorName: comment.author?.name || comment.author?.login || 'Unknown author',
|
|
authorLogin: comment.author?.login || null,
|
|
avatarUrl: comment.author?.avatarUrl || null,
|
|
createdAt: comment.createdAt,
|
|
context: 'General comment',
|
|
path: null as string | null,
|
|
line: null as number | null,
|
|
}));
|
|
|
|
const review = (commentsDetails?.reviewComments ?? []).map((comment) => ({
|
|
id: `review-${comment.id}`,
|
|
body: comment.body || '',
|
|
authorName: comment.author?.name || comment.author?.login || 'Unknown author',
|
|
authorLogin: comment.author?.login || null,
|
|
avatarUrl: comment.author?.avatarUrl || null,
|
|
createdAt: comment.createdAt,
|
|
context: 'Code review comment',
|
|
path: comment.path || null,
|
|
line: comment.line ?? null,
|
|
}));
|
|
|
|
const all = [...issue, ...review];
|
|
all.sort((a, b) => {
|
|
const aTs = a.createdAt ? Date.parse(a.createdAt) : 0;
|
|
const bTs = b.createdAt ? Date.parse(b.createdAt) : 0;
|
|
const aVal = Number.isFinite(aTs) ? aTs : 0;
|
|
const bVal = Number.isFinite(bTs) ? bTs : 0;
|
|
return aVal - bVal;
|
|
});
|
|
return all;
|
|
}, [commentsDetails]);
|
|
|
|
const resolveChatDispatchTarget = React.useCallback((): ChatDispatchTarget | null => {
|
|
if (!currentSessionId) {
|
|
toast.error('No active session', { description: 'Open a chat session first.' });
|
|
return null;
|
|
}
|
|
|
|
const { currentProviderId, currentModelId, currentAgentName, currentVariant } = useConfigStore.getState();
|
|
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
|
|
const providerID = currentProviderId || lastUsedProvider?.providerID;
|
|
const modelID = currentModelId || lastUsedProvider?.modelID;
|
|
if (!providerID || !modelID) {
|
|
toast.error('No model selected');
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
sessionId: currentSessionId,
|
|
providerID,
|
|
modelID,
|
|
currentAgentName: currentAgentName ?? null,
|
|
currentVariant: currentVariant ?? null,
|
|
};
|
|
}, [currentSessionId]);
|
|
|
|
const dispatchSyntheticPrompt = React.useCallback((
|
|
target: ChatDispatchTarget,
|
|
visibleText: string,
|
|
instructionsText: string,
|
|
payloadText: string,
|
|
) => {
|
|
void useMessageStore.getState().sendMessage(
|
|
visibleText,
|
|
target.providerID,
|
|
target.modelID,
|
|
target.currentAgentName ?? undefined,
|
|
target.sessionId,
|
|
undefined,
|
|
null,
|
|
[
|
|
{ text: instructionsText, synthetic: true },
|
|
{ text: payloadText, synthetic: true },
|
|
],
|
|
target.currentVariant ?? undefined,
|
|
).catch((e) => {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to send message', { description: message });
|
|
});
|
|
}, []);
|
|
|
|
const renderCheckRunSummary = React.useCallback((run: GitHubCheckRun) => {
|
|
const status = run.status || 'unknown';
|
|
const conclusion = run.conclusion ?? undefined;
|
|
const statusText = conclusion ? `${status} / ${conclusion}` : status;
|
|
const appName = run.app?.name || run.app?.slug;
|
|
return (
|
|
<div className="space-y-2">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground truncate">{run.name}</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{appName ? `${appName} · ${statusText}` : statusText}
|
|
</div>
|
|
</div>
|
|
|
|
{run.detailsUrl ? (
|
|
<Button variant="outline" size="sm" asChild className="flex-shrink-0">
|
|
<a href={run.detailsUrl} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Open
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
{run.output?.title ? (
|
|
<div className="typography-micro text-foreground">{run.output.title}</div>
|
|
) : null}
|
|
{run.output?.summary ? (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap">
|
|
{run.output.summary}
|
|
</div>
|
|
) : null}
|
|
{run.output?.text ? (
|
|
<div className="rounded border border-border/40 bg-background/40 px-2 py-2 typography-micro text-muted-foreground whitespace-pre-wrap max-h-48 overflow-y-auto">
|
|
{run.output.text}
|
|
</div>
|
|
) : null}
|
|
|
|
{Array.isArray(run.annotations) && run.annotations.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">
|
|
Failed annotations{run.annotations.length > 20 ? ` (showing 20/${run.annotations.length})` : ''}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{run.annotations.slice(0, 20).map((annotation, idx) => (
|
|
<div key={`${annotation.path || 'file'}:${annotation.startLine || idx}:${idx}`} className="rounded border border-[var(--status-error-border)] bg-[var(--status-error-background)]/40 px-2 py-2">
|
|
<div className="typography-micro text-[var(--status-error)]">
|
|
{annotation.title || annotation.level || 'Issue'}
|
|
{annotation.path ? ` · ${annotation.path}` : ''}
|
|
{typeof annotation.startLine === 'number' ? `:${annotation.startLine}` : ''}
|
|
{typeof annotation.endLine === 'number' && annotation.endLine !== annotation.startLine ? `-${annotation.endLine}` : ''}
|
|
</div>
|
|
<div className="typography-micro text-foreground whitespace-pre-wrap mt-1">
|
|
{annotation.message}
|
|
</div>
|
|
{annotation.rawDetails ? (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap mt-1">
|
|
{annotation.rawDetails}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{run.job?.steps && run.job.steps.length > 0 ? (
|
|
<div className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Steps</div>
|
|
<div className="space-y-1">
|
|
{run.job.steps.map((step, idx) => {
|
|
const c = (step.conclusion || '').toLowerCase();
|
|
const isFail = c && !['success', 'neutral', 'skipped'].includes(c);
|
|
const stepKey = `${run.id ?? 'run'}:${run.job?.jobId ?? 'job'}:${step.number ?? idx}:${step.name}`;
|
|
const stepExpanded = expandedCheckStepKeys.has(stepKey);
|
|
if (!isFail) {
|
|
return (
|
|
<div
|
|
key={stepKey}
|
|
className="typography-micro flex w-full items-center gap-2 rounded px-2 py-1 text-muted-foreground"
|
|
>
|
|
<span className="truncate">{step.name}</span>
|
|
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<Collapsible key={stepKey} open={stepExpanded}>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setExpandedCheckStepKeys((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(stepKey)) {
|
|
next.delete(stepKey);
|
|
} else {
|
|
next.add(stepKey);
|
|
}
|
|
return next;
|
|
});
|
|
}}
|
|
className={
|
|
'typography-micro flex w-full items-center gap-2 rounded px-2 py-1 text-left ' +
|
|
(isFail ? 'bg-destructive/10 text-destructive' : 'text-muted-foreground')
|
|
}
|
|
>
|
|
{stepExpanded ? <RiArrowDownSLine className="size-4" /> : <RiArrowRightSLine className="size-4" />}
|
|
<span className="truncate">{step.name}</span>
|
|
{step.conclusion ? <span className="ml-auto flex-shrink-0">{step.conclusion}</span> : null}
|
|
</button>
|
|
<CollapsibleContent>
|
|
<div className="ml-6 mt-1 rounded border border-border/40 bg-background/40 px-2 py-2 typography-micro text-muted-foreground space-y-1">
|
|
{typeof step.number === 'number' ? <div>Step: {step.number}</div> : null}
|
|
{step.status ? <div>Status: {step.status}</div> : null}
|
|
{step.conclusion ? <div>Conclusion: {step.conclusion}</div> : null}
|
|
{step.startedAt ? <div>Started: {formatTimestamp(step.startedAt)}</div> : null}
|
|
{step.completedAt ? <div>Completed: {formatTimestamp(step.completedAt)}</div> : null}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}, [expandedCheckStepKeys, formatTimestamp]);
|
|
|
|
const sendFailedChecksToChat = React.useCallback(async () => {
|
|
setActiveMainTab('chat');
|
|
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!directory || !pr) return;
|
|
const target = resolveChatDispatchTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: true });
|
|
const runs = context.checkRuns ?? [];
|
|
const failed = runs.filter((r) => {
|
|
const conclusion = typeof r.conclusion === 'string' ? r.conclusion.toLowerCase() : '';
|
|
if (!conclusion) return false;
|
|
return !['success', 'neutral', 'skipped'].includes(conclusion);
|
|
});
|
|
|
|
if (failed.length === 0) {
|
|
toast.message('No failed checks');
|
|
return;
|
|
}
|
|
|
|
const visibleText = 'Review these PR failed checks and propose likely fixes. Do not implement until I confirm.';
|
|
const instructionsText = `Use the attached checks payload.
|
|
- Summarize what is failing.
|
|
- Prioritize check annotations/errors over generic status text.
|
|
- Identify likely root cause(s).
|
|
- Propose a minimal fix plan and verification steps.
|
|
- No speculation: ask for missing info if needed.`;
|
|
const failedAnnotations = failed.flatMap((run) => {
|
|
const annotations = Array.isArray(run.annotations) ? run.annotations : [];
|
|
return annotations.map((annotation) => ({
|
|
run: run.name,
|
|
level: annotation.level,
|
|
title: annotation.title,
|
|
path: annotation.path,
|
|
startLine: annotation.startLine,
|
|
endLine: annotation.endLine,
|
|
message: annotation.message,
|
|
rawDetails: annotation.rawDetails,
|
|
}));
|
|
});
|
|
const payloadText = `GitHub PR failed checks (JSON)\n${JSON.stringify({
|
|
repo: context.repo ?? null,
|
|
pr: context.pr ?? null,
|
|
failedChecks: failed,
|
|
failedAnnotations,
|
|
}, null, 2)}`;
|
|
|
|
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load checks', { description: message });
|
|
}
|
|
}, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]);
|
|
|
|
const sendCommentsToChat = React.useCallback(async () => {
|
|
setActiveMainTab('chat');
|
|
|
|
if (!github?.prContext) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
if (!directory || !pr) return;
|
|
const target = resolveChatDispatchTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const context = await github.prContext(directory, pr.number, { includeDiff: false, includeCheckDetails: false });
|
|
const issueComments = context.issueComments ?? [];
|
|
const reviewComments = context.reviewComments ?? [];
|
|
const total = issueComments.length + reviewComments.length;
|
|
if (total === 0) {
|
|
toast.message('No PR comments');
|
|
return;
|
|
}
|
|
|
|
const visibleText = 'Review these PR comments and propose the required changes and next actions. Do not implement until I confirm.';
|
|
const instructionsText = `Use the attached comments payload.
|
|
- Identify required vs optional changes.
|
|
- Call out intent/implementation mismatch if present.
|
|
- Propose a minimal plan and verification steps.
|
|
- No speculation: ask for missing info if needed.`;
|
|
const payloadText = `GitHub PR comments (JSON)\n${JSON.stringify({
|
|
repo: context.repo ?? null,
|
|
pr: context.pr ?? null,
|
|
issueComments,
|
|
reviewComments,
|
|
}, null, 2)}`;
|
|
|
|
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to load PR comments', { description: message });
|
|
}
|
|
}, [directory, dispatchSyntheticPrompt, github, pr, resolveChatDispatchTarget, setActiveMainTab]);
|
|
|
|
const sendSingleCommentToChat = React.useCallback((comment: TimelineCommentItem) => {
|
|
setCommentsDialogOpen(false);
|
|
setActiveMainTab('chat');
|
|
|
|
const target = resolveChatDispatchTarget();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const visibleText = 'Address this comment from PR and propose required changes. Do not implement until I confirm.';
|
|
const instructionsText = `Use the attached single-comment payload.
|
|
- Explain what the reviewer is asking for.
|
|
- Identify exact code areas likely impacted.
|
|
- Propose a minimal implementation plan and verification steps.
|
|
- Call out ambiguity and ask focused follow-up questions if needed.`;
|
|
const payloadText = `GitHub PR comment (JSON)\n${JSON.stringify({
|
|
repo: commentsDetails?.repo ?? null,
|
|
pr: commentsDetails?.pr ?? pr ?? null,
|
|
comment,
|
|
}, null, 2)}`;
|
|
|
|
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
|
|
}, [commentsDetails, dispatchSyntheticPrompt, pr, resolveChatDispatchTarget, setActiveMainTab]);
|
|
|
|
React.useEffect(() => {
|
|
statusRef.current = status;
|
|
}, [status]);
|
|
|
|
const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => {
|
|
if (!canShow) return;
|
|
if (options?.onlyExistingPr && !statusRef.current?.pr) {
|
|
return;
|
|
}
|
|
if (!options?.force && Date.now() - lastRefreshAtRef.current < PR_REVALIDATE_TTL_MS) {
|
|
return;
|
|
}
|
|
if (isRefreshInFlightRef.current) {
|
|
return;
|
|
}
|
|
|
|
isRefreshInFlightRef.current = true;
|
|
lastRefreshAtRef.current = Date.now();
|
|
|
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
|
setStatus({ connected: false });
|
|
setError(null);
|
|
if (!options?.silent) {
|
|
setIsLoading(false);
|
|
}
|
|
if (options?.markInitialResolved !== false) {
|
|
setIsInitialStatusResolved(true);
|
|
}
|
|
isRefreshInFlightRef.current = false;
|
|
return;
|
|
}
|
|
if (!github?.prStatus) {
|
|
setStatus(null);
|
|
setError('GitHub runtime API unavailable');
|
|
if (options?.markInitialResolved !== false) {
|
|
setIsInitialStatusResolved(true);
|
|
}
|
|
isRefreshInFlightRef.current = false;
|
|
return;
|
|
}
|
|
if (!options?.silent) {
|
|
setIsLoading(true);
|
|
}
|
|
setError(null);
|
|
try {
|
|
const next = await github.prStatus(directory, branch, selectedRemote?.name);
|
|
setStatus((prev) => {
|
|
const nextPr = next.pr;
|
|
const prevPr = prev?.pr;
|
|
// Some runtimes occasionally return PR status without body.
|
|
// Keep already hydrated description for the same PR number.
|
|
const shouldCarryBody = Boolean(
|
|
nextPr
|
|
&& prevPr
|
|
&& nextPr.number === prevPr.number
|
|
&& (!nextPr.body || !nextPr.body.trim())
|
|
&& typeof prevPr.body === 'string'
|
|
&& prevPr.body.trim().length > 0,
|
|
);
|
|
|
|
if (!shouldCarryBody || !nextPr) {
|
|
return next;
|
|
}
|
|
|
|
const carriedBody = prevPr?.body;
|
|
if (!carriedBody) {
|
|
return next;
|
|
}
|
|
|
|
return {
|
|
...next,
|
|
pr: {
|
|
...nextPr,
|
|
body: carriedBody,
|
|
},
|
|
};
|
|
});
|
|
if (next.connected === false) {
|
|
setError(null);
|
|
}
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
setError(message || 'Failed to load PR status');
|
|
} finally {
|
|
if (!options?.silent) {
|
|
setIsLoading(false);
|
|
}
|
|
if (options?.markInitialResolved !== false) {
|
|
setIsInitialStatusResolved(true);
|
|
}
|
|
isRefreshInFlightRef.current = false;
|
|
}
|
|
}, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus, selectedRemote?.name]);
|
|
|
|
// Refetch PR status when selected remote changes
|
|
const handleRemoteChange = React.useCallback((remote: GitRemote) => {
|
|
setSelectedRemote(remote);
|
|
// Clear current status and refetch
|
|
setStatus(null);
|
|
setError(null);
|
|
lastRefreshAtRef.current = 0; // Force refresh
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
|
|
const statusSnapshot = pullRequestStatusSnapshots.get(snapshotKey) ?? null;
|
|
setTitle(snapshot?.title ?? branchToTitle(branch));
|
|
setBody(snapshot?.body ?? '');
|
|
setDraft(snapshot?.draft ?? false);
|
|
setTargetBaseBranch(snapshot?.targetBaseBranch ? normalizeBranchRef(snapshot.targetBaseBranch) : normalizeBranchRef(baseBranch));
|
|
setSelectedRemote(
|
|
pickInitialPrRemote(remotes, {
|
|
selectedRemoteName: snapshot?.selectedRemoteName,
|
|
trackingBranch,
|
|
})
|
|
);
|
|
setStatus(statusSnapshot);
|
|
setError(null);
|
|
setIsInitialStatusResolved(Boolean(statusSnapshot));
|
|
void refresh({ force: true, markInitialResolved: true });
|
|
}, [baseBranch, branch, refresh, remotes, snapshotKey, trackingBranch]);
|
|
|
|
// Refetch when selected remote changes
|
|
React.useEffect(() => {
|
|
if (selectedRemote) {
|
|
void refresh({ force: true, markInitialResolved: true });
|
|
}
|
|
}, [selectedRemote, refresh]);
|
|
|
|
React.useEffect(() => {
|
|
const onFocus = () => {
|
|
void refresh({ force: true, silent: true });
|
|
};
|
|
const onVisibility = () => {
|
|
if (document.visibilityState === 'visible') {
|
|
void refresh({ force: true, silent: true });
|
|
}
|
|
};
|
|
|
|
window.addEventListener('focus', onFocus);
|
|
document.addEventListener('visibilitychange', onVisibility);
|
|
return () => {
|
|
window.removeEventListener('focus', onFocus);
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
|
};
|
|
}, [refresh]);
|
|
|
|
React.useEffect(() => {
|
|
const interval = window.setInterval(() => {
|
|
if (document.visibilityState !== 'visible') {
|
|
return;
|
|
}
|
|
|
|
const hasPr = Boolean(statusRef.current?.pr);
|
|
if (!hasPr) {
|
|
const now = Date.now();
|
|
const shouldRunDiscovery = now - lastDiscoveryPollAtRef.current >= PR_DISCOVERY_INTERVAL_MS;
|
|
if (!shouldRunDiscovery) {
|
|
return;
|
|
}
|
|
lastDiscoveryPollAtRef.current = now;
|
|
void refresh({ force: true, silent: true });
|
|
return;
|
|
}
|
|
|
|
void refresh({ onlyExistingPr: true, force: true, silent: true });
|
|
}, PR_REVALIDATE_INTERVAL_MS);
|
|
|
|
return () => {
|
|
window.clearInterval(interval);
|
|
};
|
|
}, [refresh]);
|
|
|
|
React.useEffect(() => {
|
|
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
|
setStatus({ connected: false });
|
|
setError(null);
|
|
}
|
|
}, [githubAuthChecked, githubAuthStatus]);
|
|
|
|
React.useEffect(() => {
|
|
if (!directory || !branch) {
|
|
return;
|
|
}
|
|
pullRequestDraftSnapshots.set(snapshotKey, {
|
|
title,
|
|
body,
|
|
draft,
|
|
additionalContext,
|
|
targetBaseBranch,
|
|
selectedRemoteName: selectedRemote?.name,
|
|
});
|
|
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]);
|
|
|
|
React.useEffect(() => {
|
|
if (!status) {
|
|
return;
|
|
}
|
|
pullRequestStatusSnapshots.set(snapshotKey, status);
|
|
}, [snapshotKey, status]);
|
|
|
|
const generateDescription = React.useCallback(async () => {
|
|
if (isGenerating) return;
|
|
if (!directory) return;
|
|
setIsGenerating(true);
|
|
try {
|
|
const zenModel = useConfigStore.getState().settingsZenModel;
|
|
const generated = await generatePullRequestDescription(directory, {
|
|
base: targetBaseBranch,
|
|
head: branch,
|
|
context: additionalContext,
|
|
...(zenModel ? { zenModel } : {}),
|
|
});
|
|
|
|
if (generated.title?.trim()) {
|
|
setTitle(generated.title.trim());
|
|
}
|
|
if (generated.body?.trim()) {
|
|
setBody(generated.body.trim());
|
|
}
|
|
onGeneratedDescription?.();
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to generate description', { description: message });
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
}, [branch, directory, isGenerating, additionalContext, onGeneratedDescription, targetBaseBranch]);
|
|
|
|
const createPr = React.useCallback(async () => {
|
|
if (!github?.prCreate) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
const trimmedTitle = title.trim();
|
|
if (!trimmedTitle) {
|
|
toast.error('Title is required');
|
|
return;
|
|
}
|
|
|
|
const trimmedBase = targetBaseBranch.trim();
|
|
if (!trimmedBase) {
|
|
toast.error('Base branch is required');
|
|
return;
|
|
}
|
|
if (trimmedBase === branch) {
|
|
toast.error('Base branch must differ from head branch');
|
|
return;
|
|
}
|
|
|
|
setIsCreating(true);
|
|
try {
|
|
// Let the server determine the head source from tracking info
|
|
// The server will check the branch's tracking remote and use that
|
|
const pr = await github.prCreate({
|
|
directory,
|
|
title: trimmedTitle,
|
|
head: branch,
|
|
base: trimmedBase,
|
|
...(body.trim() ? { body } : {}),
|
|
draft,
|
|
...(selectedRemote ? { remote: selectedRemote.name } : {}),
|
|
});
|
|
toast.success('PR created');
|
|
setStatus((prev) => (prev ? { ...prev, pr } : prev));
|
|
await refresh({ force: true });
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to create PR', { description: message });
|
|
} finally {
|
|
setIsCreating(false);
|
|
}
|
|
}, [body, branch, directory, draft, github, refresh, selectedRemote, targetBaseBranch, title]);
|
|
|
|
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prMerge) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
setIsMerging(true);
|
|
try {
|
|
const result = await github.prMerge({ directory, number: pr.number, method: mergeMethod });
|
|
if (result.merged) {
|
|
toast.success('PR merged');
|
|
} else {
|
|
toast.message('PR not merged', { description: result.message || 'Not mergeable' });
|
|
}
|
|
await refresh({ force: true });
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Merge failed', { description: message });
|
|
if (pr.url) {
|
|
void openExternal(pr.url);
|
|
}
|
|
} finally {
|
|
setIsMerging(false);
|
|
}
|
|
}, [directory, github, mergeMethod, refresh]);
|
|
|
|
const markReady = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prReady) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
setIsMarkingReady(true);
|
|
try {
|
|
await github.prReady({ directory, number: pr.number });
|
|
toast.success('Marked ready for review');
|
|
await refresh({ force: true });
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to mark ready', { description: message });
|
|
if (pr.url) {
|
|
void openExternal(pr.url);
|
|
}
|
|
} finally {
|
|
setIsMarkingReady(false);
|
|
}
|
|
}, [directory, github, refresh]);
|
|
|
|
const updatePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
|
if (!github?.prUpdate) {
|
|
toast.error('GitHub runtime API unavailable');
|
|
return;
|
|
}
|
|
|
|
const trimmedTitle = editTitle.trim();
|
|
if (!trimmedTitle) {
|
|
toast.error('Title is required');
|
|
return;
|
|
}
|
|
|
|
setIsUpdating(true);
|
|
try {
|
|
const updated = await github.prUpdate({
|
|
directory,
|
|
number: pr.number,
|
|
title: trimmedTitle,
|
|
body: editBody,
|
|
});
|
|
setStatus((prev) => (prev
|
|
? {
|
|
...prev,
|
|
pr: {
|
|
...(prev.pr ?? pr),
|
|
...updated,
|
|
},
|
|
}
|
|
: prev));
|
|
setIsEditingPr(false);
|
|
toast.success('PR updated');
|
|
await refresh({ force: true });
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e);
|
|
toast.error('Failed to update PR', { description: message });
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
}, [directory, editBody, editTitle, github, refresh]);
|
|
|
|
if (!canShow) {
|
|
return null;
|
|
}
|
|
|
|
const repoUrl = status?.repo?.url || null;
|
|
const checks = status?.checks ?? null;
|
|
const canMerge = Boolean(status?.canMerge);
|
|
const isConnected = Boolean(status?.connected);
|
|
const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false;
|
|
const prVisualState = getPrVisualState(status);
|
|
const prColorVar = prVisualState ? `var(--pr-${prVisualState})` : 'var(--status-info)';
|
|
const PrStateIcon = prVisualState === 'draft'
|
|
? RiGitPrDraftLine
|
|
: prVisualState === 'merged'
|
|
? RiGitMergeLine
|
|
: prVisualState === 'closed'
|
|
? RiGitClosePullRequestLine
|
|
: RiGitPullRequestLine;
|
|
|
|
const containerClassName =
|
|
variant === 'framed'
|
|
? 'rounded-xl border border-border/60 bg-background/70 overflow-hidden'
|
|
: 'border-0 bg-transparent rounded-none';
|
|
const headerClassName =
|
|
variant === 'framed'
|
|
? 'px-3 py-2 border-b border-border/40 flex flex-col gap-1'
|
|
: 'px-0 py-3 border-b border-border/40 flex flex-col gap-1';
|
|
const bodyClassName = variant === 'framed' ? 'flex flex-col gap-3 p-3' : 'flex flex-col gap-3 py-3';
|
|
|
|
return (
|
|
<section className={containerClassName}>
|
|
<div className={headerClassName}>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
{pr ? (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-6 shrink-0 items-center justify-center rounded-md border border-border/60 bg-background/70 hover:bg-interactive-hover/60"
|
|
onClick={() => void openExternal(pr.url)}
|
|
aria-label="Open PR on GitHub"
|
|
>
|
|
<PrStateIcon className="size-4 shrink-0" style={{ color: prColorVar }} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Open PR on GitHub</p></TooltipContent>
|
|
</Tooltip>
|
|
) : (
|
|
<PrStateIcon className="size-4 shrink-0" style={{ color: 'var(--surface-muted-foreground)' }} />
|
|
)}
|
|
<h3 className="typography-ui-header font-semibold text-foreground truncate">Pull Request</h3>
|
|
{pr ? (
|
|
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{isLoading ? <RiLoader4Line className="size-4 animate-spin text-muted-foreground" /> : null}
|
|
{checks ? (
|
|
<span className="inline-flex items-center gap-2 typography-micro text-muted-foreground">
|
|
<span className={`h-2 w-2 rounded-full ${statusColor(checks.state)}`} />
|
|
{checks.total > 0 ? `${checks.success}/${checks.total} checks` : `${checks.state} checks`}
|
|
</span>
|
|
) : null}
|
|
{hasMultipleRemotes ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="sm" className="h-6 px-2 gap-1">
|
|
<span className="typography-micro">{selectedRemote?.name}</span>
|
|
<RiArrowDownSLine className="size-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="min-w-[200px]">
|
|
{remotes.map((remote) => (
|
|
<DropdownMenuItem
|
|
key={remote.name}
|
|
onSelect={() => handleRemoteChange(remote)}
|
|
>
|
|
<div className="flex flex-col">
|
|
<span className="typography-ui-label text-foreground">
|
|
{remote.name}
|
|
{remote.name === selectedRemote?.name && (
|
|
<span className="ml-2 text-primary">✓</span>
|
|
)}
|
|
</span>
|
|
<span className="typography-meta text-muted-foreground truncate">
|
|
{remote.pushUrl}
|
|
</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{pr ? (
|
|
<div className="typography-micro text-muted-foreground">
|
|
<span style={{ color: prColorVar }}>
|
|
{pr.state}{pr.draft ? ' (draft)' : ''}
|
|
</span>
|
|
{pr.mergeable === false ? ' · not mergeable' : ''}
|
|
{pr.state === 'open' && typeof pr.mergeableState === 'string' && pr.mergeableState && pr.mergeableState !== 'unknown'
|
|
? ` · ${pr.mergeableState}`
|
|
: ''}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className={bodyClassName}>
|
|
{shouldShowConnectionNotice ? (
|
|
<div className="space-y-2">
|
|
<div className="typography-meta text-muted-foreground">
|
|
GitHub not connected. Connect your GitHub account in settings.
|
|
</div>
|
|
<Button variant="outline" size="sm" onClick={openGitHubSettings} className="w-fit">
|
|
Open settings
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<div className="space-y-2">
|
|
<div className="typography-ui-label text-foreground">PR status unavailable</div>
|
|
<div className="typography-meta text-muted-foreground break-words">{error}</div>
|
|
{repoUrl ? (
|
|
<Button variant="outline" size="sm" asChild className="w-fit">
|
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Open Repo
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{!pr && !isInitialStatusResolved && !error && !shouldShowConnectionNotice ? (
|
|
<div className="flex items-center gap-2 typography-micro text-muted-foreground">
|
|
<RiLoader4Line className="size-4 animate-spin" />
|
|
Checking PR status...
|
|
</div>
|
|
) : pr ? (
|
|
<div className="flex flex-col gap-2">
|
|
<div className="flex flex-col gap-3">
|
|
<div className="min-w-0">
|
|
{isEditingPr ? (
|
|
<div className="space-y-2">
|
|
<Input
|
|
value={editTitle}
|
|
onChange={(e) => setEditTitle(e.target.value)}
|
|
placeholder="PR title"
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
<Textarea
|
|
value={editBody}
|
|
onChange={(e) => setEditBody(e.target.value)}
|
|
className="min-h-[120px] bg-background/80"
|
|
placeholder="Describe this PR"
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="typography-markdown text-xl font-semibold text-foreground break-words leading-snug">{pr.title}</div>
|
|
{pr.body?.trim() ? (
|
|
<SimpleMarkdownRenderer
|
|
content={pr.body}
|
|
className="typography-markdown-body text-muted-foreground break-words mt-1"
|
|
/>
|
|
) : (
|
|
<div className="typography-micro text-muted-foreground whitespace-pre-wrap break-words mt-1">
|
|
{isHydratingCurrentPrBody ? 'Loading description...' : 'No description provided.'}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{canMerge && pr.draft ? (
|
|
<div className="typography-micro text-muted-foreground">
|
|
Draft PRs must be marked ready before merge.
|
|
</div>
|
|
) : null}
|
|
{!canMerge ? (
|
|
<div className="typography-micro text-muted-foreground">No merge permission; use Open in GitHub.</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="order-first w-full flex flex-wrap items-center gap-2">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{pr.state === 'open' ? (
|
|
isEditingPr ? (
|
|
<>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={() => {
|
|
setIsEditingPr(false);
|
|
setEditTitle(pr.title || '');
|
|
setEditBody(pr.body || '');
|
|
}}
|
|
disabled={isUpdating}
|
|
aria-label="Cancel editing"
|
|
>
|
|
<RiCloseLine className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Cancel editing</p></TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={() => updatePr(pr)}
|
|
disabled={isUpdating || !editTitle.trim()}
|
|
aria-label="Save PR title and description"
|
|
>
|
|
{isUpdating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiCheckLine className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Save PR title and description</p></TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
) : (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={() => setIsEditingPr(true)}
|
|
aria-label="Edit PR title and description"
|
|
>
|
|
<RiEditLine className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Edit PR title and description</p></TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
) : null}
|
|
|
|
{checks ? (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={openChecksDialog}
|
|
disabled={isLoadingCheckDetails}
|
|
aria-label="Open checks details"
|
|
>
|
|
{isLoadingCheckDetails ? <RiLoader4Line className="size-4 animate-spin" /> : <RiInformationLine className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Open checks details</p></TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
{checks?.failure ? (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
|
onClick={sendFailedChecksToChat}
|
|
aria-label="Resolve failed checks with agent"
|
|
>
|
|
<RiErrorWarningLine className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Resolve failed checks with agent</p></TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={openCommentsDialog}
|
|
aria-label="Open PR comments"
|
|
>
|
|
<RiChat4Line className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Open PR comments</p></TooltipContent>
|
|
</Tooltip>
|
|
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0 border-[var(--status-success-border)] bg-[var(--status-success-background)] text-[var(--status-success)]"
|
|
onClick={sendCommentsToChat}
|
|
aria-label="Share comments with agent"
|
|
>
|
|
<RiAiGenerate2 className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Share comments with agent</p></TooltipContent>
|
|
</Tooltip>
|
|
|
|
{canMerge && pr.draft && pr.state === 'open' ? (
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={() => markReady(pr)}
|
|
disabled={isMarkingReady || isMerging || isUpdating || isEditingPr}
|
|
aria-label="Mark PR ready for review"
|
|
>
|
|
{isMarkingReady ? <RiLoader4Line className="size-4 animate-spin" /> : <RiCheckboxCircleLine className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Mark PR ready for review</p></TooltipContent>
|
|
</Tooltip>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="ml-auto flex items-center gap-2">
|
|
{canMerge ? (
|
|
<>
|
|
<Select
|
|
value={mergeMethod}
|
|
onValueChange={(value) => setMergeMethod(value as MergeMethod)}
|
|
disabled={isMerging || pr.state !== 'open'}
|
|
>
|
|
<SelectTrigger size="lg" className="h-8 w-auto min-w-0">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="squash">Squash</SelectItem>
|
|
<SelectItem value="merge">Merge</SelectItem>
|
|
<SelectItem value="rebase">Rebase</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="sm"
|
|
className="w-8 px-0"
|
|
onClick={() => mergePr(pr)}
|
|
disabled={isMerging || isMarkingReady || pr.state !== 'open' || pr.draft || isUpdating || isEditingPr}
|
|
aria-label="Merge pull request"
|
|
>
|
|
{isMerging ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitMergeLine className="size-4" />}
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Merge pull request</p></TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<div className="typography-ui-label text-foreground">Create PR</div>
|
|
<div className="typography-micro text-muted-foreground truncate">
|
|
{branch} → {targetBaseBranch}
|
|
</div>
|
|
</div>
|
|
{repoUrl ? (
|
|
<Button variant="outline" size="sm" asChild>
|
|
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
|
|
<RiExternalLinkLine className="size-4" />
|
|
Repo
|
|
</a>
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Title</div>
|
|
<Input
|
|
value={title}
|
|
onChange={(e) => setTitle(e.target.value)}
|
|
placeholder="PR title"
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
</label>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Base branch</div>
|
|
{availableBaseBranches.length > 0 ? (
|
|
<Select value={targetBaseBranch} onValueChange={setTargetBaseBranch}>
|
|
<SelectTrigger className="h-9">
|
|
<SelectValue placeholder="Select base branch" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{availableBaseBranches.map((candidate) => (
|
|
<SelectItem key={candidate} value={candidate}>{candidate}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={targetBaseBranch}
|
|
onChange={(e) => setTargetBaseBranch(e.target.value)}
|
|
placeholder="main"
|
|
/>
|
|
)}
|
|
</label>
|
|
|
|
<label className="space-y-1">
|
|
<div className="typography-micro text-muted-foreground">Description</div>
|
|
<Textarea
|
|
value={body}
|
|
onChange={(e) => setBody(e.target.value)}
|
|
className="min-h-[110px] bg-background/80"
|
|
placeholder="What changed and why"
|
|
autoCorrect={hasTouchInput ? "on" : "off"}
|
|
autoCapitalize={hasTouchInput ? "sentences" : "off"}
|
|
spellCheck={hasTouchInput}
|
|
/>
|
|
</label>
|
|
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-pressed={draft}
|
|
onClick={() => setDraft((v) => !v)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === ' ' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
setDraft((v) => !v);
|
|
}
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDraft((v) => !v);
|
|
}}
|
|
aria-label="Toggle draft PR"
|
|
className="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
>
|
|
{draft ? (
|
|
<RiCheckboxLine className="size-4 text-primary" />
|
|
) : (
|
|
<RiCheckboxBlankLine className="size-4" />
|
|
)}
|
|
</button>
|
|
<span className="typography-ui-label text-foreground select-none">Draft</span>
|
|
</div>
|
|
|
|
{/* Additional Context Section */}
|
|
{isMobile ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="typography-micro text-muted-foreground">
|
|
Additional context (optional)
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsContextSheetOpen(true)}
|
|
>
|
|
{additionalContext.trim() ? 'Edit' : 'Add'}
|
|
</Button>
|
|
</div>
|
|
{additionalContext.trim() && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="inline-flex items-center rounded-full bg-[var(--interactive-selection)] px-2 py-0.5 text-xs text-[var(--interactive-selection-foreground)]">
|
|
Context added
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Collapsible open={isContextOpen} onOpenChange={setIsContextOpen}>
|
|
<CollapsibleTrigger className="flex w-full items-center justify-between rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-3 py-2 hover:bg-[var(--interactive-hover)]">
|
|
<span className="typography-micro text-muted-foreground">
|
|
Additional context (optional)
|
|
</span>
|
|
<span className="typography-micro text-[var(--primary-base)]">
|
|
{isContextOpen ? 'Hide' : additionalContext.trim() ? 'Edit' : 'Add'}
|
|
</span>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<div className="mt-2 space-y-2 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-3">
|
|
<Textarea
|
|
value={additionalContext}
|
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
|
className="min-h-[100px] bg-transparent"
|
|
placeholder="Explain why this change is needed... Mention how to test (commands / steps)... Call out risks / rollout plan..."
|
|
/>
|
|
<p className="typography-micro text-muted-foreground">
|
|
This text is only used to guide PR generation.
|
|
</p>
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
)}
|
|
|
|
{/* Mobile Sheet for Context */}
|
|
<MobileOverlayPanel
|
|
open={isContextSheetOpen}
|
|
onClose={() => setIsContextSheetOpen(false)}
|
|
title="Additional context"
|
|
footer={
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setIsContextSheetOpen(false)}
|
|
className="w-full"
|
|
>
|
|
Done
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<Textarea
|
|
value={additionalContext}
|
|
onChange={(e) => setAdditionalContext(e.target.value)}
|
|
className="min-h-[200px] bg-transparent"
|
|
placeholder="Explain why this change is needed... Mention how to test (commands / steps)... Call out risks / rollout plan..."
|
|
autoFocus
|
|
/>
|
|
<p className="typography-micro text-muted-foreground">
|
|
This text is only used to guide PR generation.
|
|
</p>
|
|
</div>
|
|
</MobileOverlayPanel>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={generateDescription}
|
|
disabled={isGenerating || isCreating}
|
|
>
|
|
{isGenerating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiAiGenerate2 className="size-4 text-primary" />}
|
|
Generate
|
|
</Button>
|
|
<div className="flex-1" />
|
|
<Button
|
|
size="sm"
|
|
className="min-w-[7.5rem] justify-center gap-2"
|
|
onClick={createPr}
|
|
disabled={isCreating || !isConnected || !targetBaseBranch.trim() || targetBaseBranch.trim() === branch}
|
|
>
|
|
<span className="inline-flex size-4 items-center justify-center">
|
|
{isCreating ? <RiLoader4Line className="size-4 animate-spin" /> : <RiGitPullRequestLine className="size-4" />}
|
|
</span>
|
|
<span>Create PR</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Dialog open={checksDialogOpen} onOpenChange={setChecksDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col min-h-0">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<RiGitPullRequestLine className="h-5 w-5" />
|
|
Check Details
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{pr ? `PR #${pr.number}` : 'Pull request'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex-1 min-h-0 overflow-y-auto mt-2">
|
|
{isLoadingCheckDetails ? (
|
|
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : null}
|
|
|
|
{!isLoadingCheckDetails ? (
|
|
<div className="space-y-3">
|
|
{Array.isArray(checkDetails?.checkRuns) && checkDetails?.checkRuns.length > 0 ? (
|
|
checkDetails.checkRuns.map((run, idx) => {
|
|
const key = `${run.id ?? 'run'}:${run.job?.jobId ?? 'job'}:${run.name}:${idx}`;
|
|
return (
|
|
<div key={key} className="p-1">
|
|
{renderCheckRunSummary(run)}
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="text-center text-muted-foreground py-8">No check details available.</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={commentsDialogOpen} onOpenChange={setCommentsDialogOpen}>
|
|
<DialogContent className="max-w-2xl max-h-[82vh] min-h-[38rem] flex flex-col gap-2">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<RiGitPullRequestLine className="h-5 w-5" />
|
|
PR Comments
|
|
{pr ? (
|
|
<span className="typography-meta text-muted-foreground">PR #{pr.number}</span>
|
|
) : null}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<ScrollShadow className="mt-2 max-h-[66vh] overflow-y-auto overlay-scrollbar-target overlay-scrollbar-container">
|
|
{isLoadingCommentsDetails ? (
|
|
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
|
|
<RiLoader4Line className="h-4 w-4 animate-spin" />
|
|
Loading...
|
|
</div>
|
|
) : null}
|
|
|
|
{!isLoadingCommentsDetails ? (
|
|
<div className="space-y-4">
|
|
{timelineComments.length > 0 ? (
|
|
<div className="relative pl-3">
|
|
<div>
|
|
{timelineComments.map((comment, idx) => {
|
|
const initial = (comment.authorName || '?').charAt(0).toUpperCase();
|
|
const isLast = idx === timelineComments.length - 1;
|
|
return (
|
|
<div key={comment.id} className="relative pl-10 pb-5 last:pb-0">
|
|
{!isLast ? <div className="absolute left-4 top-[2.375rem] bottom-[0.375rem] w-px bg-border/60" /> : null}
|
|
<div className="absolute left-0 top-0 z-10 flex size-8 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-surface-elevated text-xs text-muted-foreground">
|
|
{comment.avatarUrl ? (
|
|
<img src={comment.avatarUrl} alt={comment.authorName} className="h-full w-full object-cover" />
|
|
) : (
|
|
<span>{initial}</span>
|
|
)}
|
|
</div>
|
|
<div className="rounded-lg bg-surface-elevated px-3 pt-0 pb-3 space-y-2">
|
|
<div className="flex flex-col items-start gap-1 typography-micro text-muted-foreground sm:flex-row sm:flex-wrap sm:items-center sm:gap-x-1 sm:gap-y-1">
|
|
<span className="text-foreground whitespace-nowrap">
|
|
{comment.authorName}
|
|
{comment.authorLogin && comment.authorLogin !== comment.authorName ? ` · @${comment.authorLogin}` : ''}
|
|
</span>
|
|
{comment.createdAt ? <span className="whitespace-nowrap">{formatTimestamp(comment.createdAt)}</span> : null}
|
|
<Tooltip delayDuration={300}>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-0 has-[>svg]:px-0 sm:px-2 sm:has-[>svg]:px-2.5 text-[var(--status-success)] hover:bg-[var(--status-success-background)] hover:text-[var(--status-success)] justify-start"
|
|
onClick={() => sendSingleCommentToChat(comment)}
|
|
aria-label="Send this comment to agent"
|
|
>
|
|
<RiAiGenerate2 className="size-3.5" />
|
|
Send to agent
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent><p>Send this comment to agent</p></TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<div className="typography-micro text-muted-foreground">
|
|
{comment.context}
|
|
{comment.path ? ` · ${comment.path}` : ''}
|
|
{comment.line ? `:${comment.line}` : ''}
|
|
</div>
|
|
<SimpleMarkdownRenderer
|
|
content={linkifyMentionsMarkdown(comment.body)}
|
|
className={[
|
|
'typography-markdown-body text-foreground break-words [&_a]:no-underline [&_a:hover]:no-underline',
|
|
selfMentionHighlightClass,
|
|
].filter(Boolean).join(' ')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-center text-muted-foreground py-8">No comments found.</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</ScrollShadow>
|
|
|
|
</DialogContent>
|
|
</Dialog>
|
|
</section>
|
|
);
|
|
};
|