refactor: modularize session sidebar and add GitHub PR tracking (#610)
* feat: switch sessions sidebar to global paginated loading with archived flow Load sessions via global endpoint with progressive 500-item pagination and legacy fallback Add dedicated archived sidebar section for archived and unassigned sessions Change remove behavior to archive outside archived and hard-delete inside archived * feat: improve archived sessions UX and folder persistence Archive sessions on worktree removal while keeping worktree deletion Streamline archived sidebar actions, icons, metadata, and tooltips Persist session folders to ~/.config/openchamber/sessions-directories.json with startup hydration * fix: align archived session actions and clean empty archived folders Apply archived dropdown behavior consistently for folder-contained sessions Remove archived-only folder actions while keeping standard folder behavior elsewhere Auto-prune empty archived folders during session cleanup and persistence sync * refactor: modularize session sidebar and stabilize behavior Split monolithic sidebar logic into focused hooks and components Kept session, archive, folder, and project interactions working with cleaner state persistence Added sidebar DOCUMENTATION.md summarizing file roles and refactor outcomes * fix: improve fork PR detection and smart remote tracking Added centralized PR status store for shared polling and refresh Auto-selects the remote that has an existing PR when current remote has none Stops periodic polling for closed or merged PRs to reduce unnecessary requests * fix: make chat and toast corners follow active theme radius Toast corners now use theme radius tokens instead of hardcoded rounding User message bubble now uses theme-configured max radius with preserved tail corner Square-corner themes now consistently affect both toasts and chat bubbles * feat: show live PR status across git view and session sidebar Added a shared GitHub PR status store with adaptive background polling and terminal-state pause Improved fork remote detection and auto-selection so existing PRs are found more reliably Updated session group headers to show clickable PR number with branch and state-colored branch icon * feat: centralize GitHub PR tracking and enrich session sidebar PR details Moved PR status polling to a single global pipeline keyed by directory and branch Improved fork-aware PR resolution and reduced duplicate GitHub status fetches across views Added richer session sidebar PR display with clickable number, state-aware styling, and structured tooltip details * fix: adjust PR indicator icon vertical alignment Fine-tuned PR indicator icon vertical alignment in session sidebar Reduced icon translate-y from 2px to 0.5px for better visual balance * feat: improve session sidebar status display * feat: enhance session display logic for minimal mode and improve dropdown menu accessibility * feat: refactor session row to include tooltip for minimal display mode
This commit is contained in:
committed by
GitHub
parent
d54e1199df
commit
a7f11121e8
@@ -54,6 +54,7 @@ import { useMessageStore } from '@/stores/messageStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubCheckRun,
|
||||
@@ -64,10 +65,6 @@ import type {
|
||||
|
||||
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':
|
||||
@@ -215,6 +212,35 @@ const pickInitialPrRemote = (
|
||||
return remotes[0] ?? null;
|
||||
};
|
||||
|
||||
const isEphemeralPrRemote = (name: string): boolean => name.startsWith('pr-');
|
||||
|
||||
const rankRemotesForAutoSelect = (
|
||||
remotes: GitRemote[],
|
||||
trackingBranch?: string,
|
||||
): GitRemote[] => {
|
||||
const trackingRemote = getTrackingRemoteName(trackingBranch);
|
||||
const byName = new Map(remotes.map((remote) => [remote.name, remote]));
|
||||
const ordered: GitRemote[] = [];
|
||||
const pushUnique = (remote: GitRemote | null | undefined) => {
|
||||
if (!remote) return;
|
||||
if (ordered.some((item) => item.name === remote.name)) return;
|
||||
ordered.push(remote);
|
||||
};
|
||||
|
||||
if (trackingRemote) {
|
||||
pushUnique(byName.get(trackingRemote));
|
||||
}
|
||||
pushUnique(byName.get('upstream'));
|
||||
pushUnique(byName.get('origin'));
|
||||
|
||||
remotes
|
||||
.filter((remote) => !isEphemeralPrRemote(remote.name))
|
||||
.forEach((remote) => pushUnique(remote));
|
||||
remotes.forEach((remote) => pushUnique(remote));
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
type TimelineCommentItem = {
|
||||
id: string;
|
||||
body: string;
|
||||
@@ -236,7 +262,6 @@ type ChatDispatchTarget = {
|
||||
};
|
||||
|
||||
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
||||
const pullRequestStatusSnapshots = new Map<string, GitHubPullRequestStatus>();
|
||||
|
||||
type TauriShell = {
|
||||
shell?: {
|
||||
@@ -293,15 +318,12 @@ export const PullRequestSection: React.FC<{
|
||||
() => 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 ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const startPrStatusWatching = useGitHubPrStatusStore((state) => state.startWatching);
|
||||
const stopPrStatusWatching = useGitHubPrStatusStore((state) => state.stopWatching);
|
||||
const refreshPrStatus = useGitHubPrStatusStore((state) => state.refresh);
|
||||
const updatePrStatus = useGitHubPrStatusStore((state) => state.updateStatus);
|
||||
|
||||
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
||||
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
||||
@@ -337,6 +359,17 @@ export const PullRequestSection: React.FC<{
|
||||
})
|
||||
);
|
||||
|
||||
const prStatusKey = React.useMemo(
|
||||
() => getGitHubPrStatusKey(directory, branch),
|
||||
[directory, branch],
|
||||
);
|
||||
const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]);
|
||||
|
||||
const isLoading = statusEntry?.isLoading ?? false;
|
||||
const status = statusEntry?.status ?? null;
|
||||
const error = statusEntry?.error ?? null;
|
||||
const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false;
|
||||
|
||||
const availableBaseBranches = React.useMemo(() => {
|
||||
const selectedRemoteName = selectedRemote?.name?.trim() || null;
|
||||
const unique = new Set<string>();
|
||||
@@ -412,13 +445,10 @@ export const PullRequestSection: React.FC<{
|
||||
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 selectedRemoteNameRef = React.useRef<string | null>(selectedRemote?.name ?? null);
|
||||
const attemptedBodyHydrationRef = React.useRef<Set<string>>(new Set());
|
||||
const lastSyncedPrNumberRef = React.useRef<number | null>(null);
|
||||
const didUserOverrideRemoteRef = React.useRef(false);
|
||||
const autoRemoteProbeDoneRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
|
||||
|
||||
@@ -454,7 +484,7 @@ export const PullRequestSection: React.FC<{
|
||||
if (!ctxPr) {
|
||||
return;
|
||||
}
|
||||
setStatus((prev) => {
|
||||
updatePrStatus(prStatusKey, (prev) => {
|
||||
if (!prev?.pr || prev.pr.number !== pr.number) {
|
||||
return prev;
|
||||
}
|
||||
@@ -478,7 +508,7 @@ export const PullRequestSection: React.FC<{
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [directory, github, pr]);
|
||||
}, [directory, github, pr, prStatusKey, updatePrStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pr) {
|
||||
@@ -923,116 +953,100 @@ export const PullRequestSection: React.FC<{
|
||||
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
|
||||
}, [commentsDetails, dispatchSyntheticPrompt, pr, resolveChatDispatchTarget, setActiveMainTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
statusRef.current = status;
|
||||
}, [status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedRemoteNameRef.current = selectedRemote?.name ?? null;
|
||||
}, [selectedRemote?.name]);
|
||||
|
||||
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, selectedRemoteNameRef.current ?? undefined);
|
||||
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]);
|
||||
await refreshPrStatus(prStatusKey, options);
|
||||
}, [prStatusKey, refreshPrStatus]);
|
||||
|
||||
// Refetch PR status when selected remote changes
|
||||
const handleRemoteChange = React.useCallback((remote: GitRemote) => {
|
||||
didUserOverrideRemoteRef.current = true;
|
||||
setSelectedRemote((prev) => (prev?.name === remote.name ? prev : remote));
|
||||
// Clear current status and refetch
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
lastRefreshAtRef.current = 0; // Force refresh
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!github?.prStatus || !canShow || remotes.length <= 1) {
|
||||
return;
|
||||
}
|
||||
if (didUserOverrideRemoteRef.current) {
|
||||
return;
|
||||
}
|
||||
if (status?.pr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const probeKey = `${snapshotKey}::${selectedRemote?.name ?? ''}`;
|
||||
if (autoRemoteProbeDoneRef.current.has(probeKey)) {
|
||||
return;
|
||||
}
|
||||
autoRemoteProbeDoneRef.current.add(probeKey);
|
||||
|
||||
const candidates = rankRemotesForAutoSelect(remotes, trackingBranch)
|
||||
.filter((remote) => remote.name !== selectedRemote?.name);
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
for (const candidate of candidates) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await github.prStatus(directory, branch, candidate.name);
|
||||
if (!next?.pr) {
|
||||
continue;
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setSelectedRemote((prev) => (prev?.name === candidate.name ? prev : candidate));
|
||||
return;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [branch, canShow, directory, github, remotes, selectedRemote?.name, snapshotKey, status?.pr, trackingBranch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensurePrStatusEntry(prStatusKey);
|
||||
setPrStatusParams(prStatusKey, {
|
||||
directory,
|
||||
branch,
|
||||
remoteName: selectedRemote?.name ?? null,
|
||||
canShow,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected: githubAuthStatus?.connected ?? null,
|
||||
});
|
||||
}, [
|
||||
branch,
|
||||
canShow,
|
||||
directory,
|
||||
ensurePrStatusEntry,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubAuthStatus?.connected,
|
||||
prStatusKey,
|
||||
selectedRemote?.name,
|
||||
setPrStatusParams,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
startPrStatusWatching(prStatusKey);
|
||||
return () => {
|
||||
stopPrStatusWatching(prStatusKey);
|
||||
};
|
||||
}, [prStatusKey, startPrStatusWatching, stopPrStatusWatching]);
|
||||
|
||||
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);
|
||||
@@ -1042,29 +1056,35 @@ export const PullRequestSection: React.FC<{
|
||||
trackingBranch,
|
||||
});
|
||||
setSelectedRemote((prev) => (prev?.name === nextRemote?.name ? prev : nextRemote));
|
||||
setStatus(statusSnapshot);
|
||||
setError(null);
|
||||
setIsInitialStatusResolved(Boolean(statusSnapshot));
|
||||
}, [baseBranch, branch, remotes, snapshotKey, trackingBranch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refresh({ force: true, markInitialResolved: true });
|
||||
}, [snapshotKey, refresh]);
|
||||
void refresh({ markInitialResolved: true });
|
||||
}, [prStatusKey, refresh]);
|
||||
|
||||
// Refetch when selected remote changes
|
||||
React.useEffect(() => {
|
||||
if (selectedRemote?.name) {
|
||||
void refresh({ force: true, markInitialResolved: true });
|
||||
if (!canShow || !selectedRemote?.name) {
|
||||
return;
|
||||
}
|
||||
}, [selectedRemote?.name, refresh]);
|
||||
void refresh({ force: true, silent: true, markInitialResolved: true });
|
||||
}, [canShow, refresh, selectedRemote?.name]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged';
|
||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||
const isStale = Date.now() - lastRefreshAt > 60_000;
|
||||
const shouldRefresh = !isTerminal && isStale;
|
||||
|
||||
const onFocus = () => {
|
||||
void refresh({ force: true, silent: true });
|
||||
if (shouldRefresh) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refresh({ force: true, silent: true });
|
||||
if (shouldRefresh) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1074,40 +1094,13 @@ export const PullRequestSection: React.FC<{
|
||||
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]);
|
||||
}, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
setStatus({ connected: false });
|
||||
setError(null);
|
||||
void refresh({ force: true, silent: true, markInitialResolved: true });
|
||||
}
|
||||
}, [githubAuthChecked, githubAuthStatus]);
|
||||
}, [githubAuthChecked, githubAuthStatus, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directory || !branch) {
|
||||
@@ -1123,13 +1116,6 @@ export const PullRequestSection: React.FC<{
|
||||
});
|
||||
}, [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;
|
||||
@@ -1194,7 +1180,7 @@ export const PullRequestSection: React.FC<{
|
||||
...(selectedRemote ? { remote: selectedRemote.name } : {}),
|
||||
});
|
||||
toast.success('PR created');
|
||||
setStatus((prev) => (prev ? { ...prev, pr } : prev));
|
||||
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
|
||||
await refresh({ force: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
@@ -1202,7 +1188,7 @@ export const PullRequestSection: React.FC<{
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [body, branch, directory, draft, github, refresh, selectedRemote, targetBaseBranch, title]);
|
||||
}, [body, branch, directory, draft, github, prStatusKey, refresh, selectedRemote, targetBaseBranch, title, updatePrStatus]);
|
||||
|
||||
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
||||
if (!github?.prMerge) {
|
||||
@@ -1270,7 +1256,7 @@ export const PullRequestSection: React.FC<{
|
||||
title: trimmedTitle,
|
||||
body: editBody,
|
||||
});
|
||||
setStatus((prev) => (prev
|
||||
updatePrStatus(prStatusKey, (prev) => (prev
|
||||
? {
|
||||
...prev,
|
||||
pr: {
|
||||
@@ -1288,7 +1274,7 @@ export const PullRequestSection: React.FC<{
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [directory, editBody, editTitle, github, refresh]);
|
||||
}, [directory, editBody, editTitle, github, prStatusKey, refresh, updatePrStatus]);
|
||||
|
||||
if (!canShow) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user