Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions apps/desktop/src/main/controllers/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,65 @@ export const openPrByUrl: IpcController<'prs:openByUrl'> = async (_event, req) =
*/
export const refreshPrs: IpcController<'prs:refresh'> = () => getContext().poller.tick();

/**
* Refresh a SINGLE PR from remote (metadata + comments) without a whole-poller tick. Fetches just this PR via the
* adapter (bypassing the discovery list), recomputes localStatus from the current user's reviewer status (remote
* authoritative, same rule as the poll), persists the updated meta, and invalidates the comments cache (broadcasts
* comments:changed → the open comments / activity / inline-diff refetch). If the head sha advanced, best-effort ensure
* the mirror has the new commits so the diff renders the new code. Returns the updated PR; the renderer then reloads the
* list locally (no network poll of other PRs).
*/
export const refreshOnePr: IpcController<'prs:refreshOne'> = async (_event, req) => {
const ctx = getContext();
const existing = await ctx.pr.findPrOrThrow(req.localId);
const adapter = ctx.pr.adapterForOrThrow(existing);
// Remote fetch of just this PR. 403/404 normalize to error codes (matching openPrByUrl); other errors bubble up.
let fresh;
try {
fresh = await adapter.prs.getSinglePullRequest(
{ projectKey: existing.repo.projectKey, repoSlug: existing.repo.repoSlug },
existing.remoteId,
);
} catch (err) {
const status = (err as { status?: number } | null)?.status;
if (status === 403) throw new AppError(ERROR_CODES.PR_FORBIDDEN, undefined, 'forbidden');
if (status === 404) throw new AppError(ERROR_CODES.PR_NOT_FOUND, undefined, 'not found');
throw err;
}
// localStatus mirrors the remote current user's reviewer status (remote authoritative, same mapping as the poll);
// when the current user is unknown (ping incomplete) keep the recorded status rather than downgrading to pending.
const me = adapter.connection.getCurrentUser();
const mineStatus = me ? fresh.reviewers.find((r) => r.name === me.name)?.status : undefined;
const localStatus = !me
? existing.localStatus
: mineStatus === 'approved'
? 'approved'
: mineStatus === 'needsWork'
? 'needs_work'
: 'pending';
const stored: StoredPullRequest = {
...fresh,
localId: existing.localId,
platform: existing.platform,
connectionId: existing.connectionId,
localStatus,
// Preserve local-only bookkeeping (a single-PR refresh isn't a discovery pass).
discoveryFilters: existing.discoveryFilters,
discoveredAt: existing.discoveredAt,
lastSeenAt: new Date().toISOString(),
};
await writePrMeta(await ctx.pr.storeForPr(req.localId), req.localId, stored);
await ctx.pr.invalidateCommentsCache(req.localId);
if (fresh.sourceRef.sha !== existing.sourceRef.sha) {
try {
await ctx.pr.ensureMirrorReadyForPr(stored);
} catch {
/* non-fatal: the diff view self-heals / surfaces a readable error if the mirror still lacks the sha */
}
}
return stored;
};

/**
* The Poller's most recent completion time (used for startup initialization).
*/
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export function registerIpcHandlers(deps: RegisterDeps): {
ipcMain.handle('prs:listArchived', pr.listArchivedPrs); // Closed (archived) PR list (read-only browsing)
ipcMain.handle('prs:openByUrl', pr.openPrByUrl); // Open a current-platform PR by URL (locate / fetch archive)
ipcMain.handle('prs:refresh', pr.refreshPrs); // Poll and refresh immediately
ipcMain.handle('prs:refreshOne', pr.refreshOnePr); // Refresh a single PR from remote (no whole-poller tick)
ipcMain.handle('prs:lastSync', pr.getLastSync); // Most recent sync time
ipcMain.handle('prs:setLocalStatus', pr.setPrStatus); // Set review status (remote first, then local)
ipcMain.handle('prs:markRead', pr.markRead); // Mark PR read (advance unread watermark)
Expand Down
22 changes: 18 additions & 4 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,25 @@ export default function App() {
selectedId,
setSelectedId,
refreshing,
refreshingPr,
merging,
reloadPrs,
triggerRefresh,
refreshPr,
setSelectedPrStatus,
mergeSelectedPr,
markRead,
} = usePullRequests({ notifyError });
// App startup / global lifecycle (boot load, language, poll / focus refresh, wizard completion, connection hot-apply)
const { boot, fatalError, lastSyncAt, needsOnboarding, completeOnboarding, refreshBootAndPrs, patchConfig } =
useBootstrap({ setPrs, reloadPrs });
const {
boot,
fatalError,
lastSyncAt,
needsOnboarding,
completeOnboarding,
refreshBootAndPrs,
patchConfig,
} = useBootstrap({ setPrs, reloadPrs });
// Layout state (left/right column widths / collapse), version update notice, store wiring, external link guard — each its own app-level hook
const {
sidebarWidth,
Expand Down Expand Up @@ -128,12 +137,13 @@ export default function App() {
// "Can engage" determination for the closed scope: merged / still-open PRs allow adding comments + AI review; declined ones are browse-only. The active scope is always engageable.
const canEngage = !archived || (selectedPr ? selectedPr.state !== 'declined' : false);

// Window-level global shortcuts (F5 auto review / DevTools / view closed / Ctrl-Cmd+B·J layout toggles) — domain logic lives in useGlobalShortcuts.
// Window-level global shortcuts (F5 refresh / Ctrl+F5 auto review / DevTools / view closed / Ctrl-Cmd+B·J layout toggles) — domain logic lives in useGlobalShortcuts.
useGlobalShortcuts({
platform: boot?.info.platform,
selectedId,
canEngage,
viewArchived,
refreshPr: (localId) => void refreshPr(localId),
setSidebarCollapsed,
setChatCollapsed,
});
Expand Down Expand Up @@ -177,7 +187,8 @@ export default function App() {
const showDiscoveryFilter = availableDiscoveryFilters.length > 0;
// Whether the platform supports the needs_work ("needs changes") review state: GitHub / Bitbucket support it, GitLab (binary approval) does not.
// Determines whether the "pending" status filter is kept under discovery categories other than "awaiting my review" (see Sidebar.visibleFilters).
const supportsNeedsWork = activeConnSummary?.capabilities.reviewStatuses.includes('needsWork') ?? false;
const supportsNeedsWork =
activeConnSummary?.capabilities.reviewStatuses.includes('needsWork') ?? false;
// The selected category may be invalid for the current platform after switching connections → fall back to the first available.
const effectiveDiscoveryFilter = availableDiscoveryFilters.includes(discoveryFilter)
? discoveryFilter
Expand All @@ -204,6 +215,7 @@ export default function App() {
prStatusFilters={visibleStatusFilters}
setPrStatusFilter={setStatusFilter}
viewArchived={viewArchived}
refreshPr={(localId) => void refreshPr(localId)}
openPrByUrl={openPrByUrl}
/>
<div className="app-body">
Expand Down Expand Up @@ -238,6 +250,8 @@ export default function App() {
onSetStatus={(s) => void setSelectedPrStatus(s)}
onMerge={() => void mergeSelectedPr()}
merging={merging}
onRefresh={() => void refreshPr(selectedPr.localId)}
refreshing={refreshingPr}
capabilities={selectedConn?.capabilities}
currentUserName={selectedConn?.user?.name ?? null}
// The closed scope hides PR lifecycle actions (merge / approve); declined / not-engageable further hides comment / draft writes.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ interface CommandPaletteProps {
setDiscoveryFilter: (filter: PrDiscoveryFilter) => void;
/** Switch to the "closed" (archived) scope (used by the PR-domain "view closed" command). */
viewArchived: () => void;
/** Refresh a single PR by localId (used by the PR-domain "Refresh PR" command for the selected PR). */
refreshPr: (localId: string) => void;
/** Open a PR of the current platform by URL (used by the PR-domain "open URL" free-text command). */
openPrByUrl: (url: string) => void | Promise<void>;
/** Selectable PR status filters (used by the PR-domain "filter by category" second-level options). */
Expand Down Expand Up @@ -88,6 +90,7 @@ export function CommandPalette({
discoveryFilters,
setDiscoveryFilter,
viewArchived,
refreshPr,
openPrByUrl,
prStatusFilters,
setPrStatusFilter,
Expand Down Expand Up @@ -126,6 +129,7 @@ export function CommandPalette({
discoveryFilters,
setDiscoveryFilter,
viewArchived,
refreshPr,
openPrByUrl,
prStatusFilters,
setPrStatusFilter,
Expand All @@ -145,6 +149,7 @@ export function CommandPalette({
discoveryFilters,
setDiscoveryFilter,
viewArchived,
refreshPr,
openPrByUrl,
prStatusFilters,
setPrStatusFilter,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,5 +93,20 @@ export function buildPrCommands(ctx: CommandContext): RootCommand[] {
run: () => ctx.togglePrList(),
});

// Refresh PR: re-fetch just the selected PR from remote (metadata + comments), not a whole-poller tick. Only shown
// when a PR is selected. Bound to bare F5 (real key match in App's window-level listener); shortcut here is display-only.
out.push({
id: 'refresh-pr',
category,
categoryEn,
title: t('commandPalette.cmdRefreshPr'),
titleEn: tEn('commandPalette.cmdRefreshPr'),
when: () => Boolean(ctx.selectedPrId),
shortcut: ['F5'],
run: () => {
if (ctx.selectedPrId) ctx.refreshPr(ctx.selectedPrId);
},
});

return out;
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ export function buildReviewCommands(ctx: CommandContext): RootCommand[] {
// Gating: only appears when a PR is selected (meaningless without one). Reentrancy guard at execution: ignore if the same PR is already running. Uses the same channel as ChatPane's
// one-click review; run state / session reflected via events + store; LLM not configured / pr-agent not ready flow back into the session as a failure from the backend.
when: () => Boolean(selectedPrId),
shortcut: ['F5'], // Run (IDE convention); single key avoids combo conflicts, see App window-level shortcuts
// Ctrl+F5 (literal Ctrl on all platforms): moved off bare F5 so an accidental F5 doesn't kick off a review; bare F5 is now "Refresh PR". See App window-level shortcuts.
shortcut: formatChord(ctx.platform, 'F5', { ctrl: true }),
run: () => {
if (selectedPrId && !isPrRunning(selectedPrId)) {
void invoke('agent:run', { localId: selectedPrId });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import type { Platform } from '@meebox/shared';
export function formatChord(
platform: Platform,
key: string,
mods: { shift?: boolean; alt?: boolean } = {},
mods: { shift?: boolean; alt?: boolean; ctrl?: boolean } = {},
): string[] {
const mac = platform === 'darwin';
// Primary modifier: normally ⌘ (mac) / Ctrl (other). With `ctrl`, force the literal Control key on both — mac shows ⌃
// instead of ⌘ — for bindings that match `e.ctrlKey` on every platform (e.g. Ctrl+F5, where ⌘F5 would hit macOS VoiceOver).
const primary = mac ? (mods.ctrl ? '⌃' : '⌘') : 'Ctrl';
const tokens = mac
? [mods.alt && '⌥', mods.shift && '⇧', '⌘', key]
: ['Ctrl', mods.shift && 'Shift', mods.alt && 'Alt', key];
? [mods.alt && '⌥', mods.shift && '⇧', primary, key]
: [primary, mods.shift && 'Shift', mods.alt && 'Alt', key];
return tokens.filter((x): x is string => Boolean(x));
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface CommandContext {
setDiscoveryFilter: (filter: PrDiscoveryFilter) => void;
/** Switch to the "closed" (archived) scope (used by the PR-domain "view closed" command). */
viewArchived: () => void;
/** Refresh a single PR by localId (re-fetch that PR from remote; used by the PR-domain "Refresh PR" command for the selected PR, also bound to bare F5). */
refreshPr: (localId: string) => void;
/** Open a PR of the current platform by URL (used by the PR-domain "open URL" free-text command): locate locally or fetch the archive then jump, popping a toast on failure. */
openPrByUrl: (url: string) => void | Promise<void>;
/** Optional PR status filter items (pending / all / conflict / mergeable, etc., already gated by platform). */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
ReviewerStatus,
StoredPullRequest,
} from '@meebox/shared';
import { ApproveIcon, GlobeIcon, NeedsWorkIcon, PullRequestIcon } from '../../common';
import { ApproveIcon, GlobeIcon, NeedsWorkIcon, PullRequestIcon, RetryIcon } from '../../common';
import { ReviewerStack } from './ReviewerStack';

/**
Expand All @@ -19,6 +19,8 @@ export function PrHeader({
currentUserName,
merging,
onMerge,
onRefresh,
refreshing = false,
onSetStatus,
hideLifecycle = false,
readOnly = false,
Expand All @@ -30,6 +32,10 @@ export function PrHeader({
currentUserName?: string | null;
merging: boolean;
onMerge: () => void;
/** Refresh PRs (re-poll + reload); wired to the neutral refresh button beside "open in browser". */
onRefresh: () => void;
/** Whether a refresh is in flight (disables the refresh button). */
refreshing?: boolean;
onSetStatus: (status: LocalPrStatus) => void;
/** Hide PR lifecycle actions (merge + review decision): always set for the closed scope. */
hideLifecycle?: boolean;
Expand Down Expand Up @@ -106,6 +112,18 @@ export function PrHeader({
>
<GlobeIcon /> {t('mainPane.openInBrowser')}
</a>
{/* Refresh (re-poll + reload): neutral icon button (no accent fill) beside "open in browser"; disabled while a refresh is in flight. Also bound to F5. */}
<button
type="button"
className="btn btn-sm btn-icon pr-header-refresh"
onClick={onRefresh}
disabled={refreshing}
aria-busy={refreshing}
title={t('mainPane.refreshTitle')}
aria-label={t('mainPane.refreshTitle')}
>
<RetryIcon size={14} />
</button>
{/* approve / needs work: current status = highlighted; clicking an already-highlighted one falls back to pending (revokes the remote mark).
"Publish comments (N)" sits to the left of the decision buttons — reviewing is two steps: post comments first (left), then make the decision (right). */}
<div className="pr-header-actions-right">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export interface PrPanelProps {
onSetStatus: (status: LocalPrStatus) => void;
onMerge: () => void;
merging?: boolean;
/** Refresh PRs (re-poll + reload); wired to the header refresh button. */
onRefresh: () => void;
/** Whether a refresh is in flight (disables the header refresh button). */
refreshing?: boolean;
capabilities?: PlatformCapabilities;
currentUserName?: string | null;
/** Hide PR lifecycle actions (merge / approval): always set for the closed scope (departed PRs no longer take review decisions / merges). */
Expand Down Expand Up @@ -62,6 +66,8 @@ export function PrPanel({
onSetStatus,
onMerge,
merging = false,
onRefresh,
refreshing = false,
capabilities,
currentUserName,
hideLifecycle = false,
Expand Down Expand Up @@ -176,6 +182,8 @@ export function PrPanel({
currentUserName={currentUserName}
merging={merging}
onMerge={onMerge}
onRefresh={onRefresh}
refreshing={refreshing}
onSetStatus={onSetStatus}
hideLifecycle={hideLifecycle}
readOnly={readOnly}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) =>
const [prs, setPrs] = useState<StoredPullRequest[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
// Single-PR refresh in flight (distinct from the whole-list `refreshing`): disables the PR header's refresh button.
const [refreshingPr, setRefreshingPr] = useState(false);
// Merge in progress: GitHub merge can be slow (mergeable is computed asynchronously); set the button to a waiting state and prevent repeated clicks.
const [merging, setMerging] = useState(false);

Expand All @@ -36,6 +38,25 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) =>
}
}, [refreshing, reloadPrs]);

// Refresh a single PR from remote (metadata + comments) without a whole-poller tick — the one remote call is this PR's
// fetch; reloadPrs afterwards is local (re-derives the list from disk, so the header / unread / diff head update).
// Comments/inline-diff refresh reactively via the comments:changed the main side broadcasts.
const refreshPr = useCallback(
async (localId: string): Promise<void> => {
if (refreshingPr) return;
setRefreshingPr(true);
try {
await invoke('prs:refreshOne', { localId });
await reloadPrs();
} catch (e) {
console.error('refresh PR failed', e);
} finally {
setRefreshingPr(false);
}
},
[refreshingPr, reloadPrs],
);

// Mark PR as read: called when the user opens a PR. First optimistically clear the local unread flags (instant feedback), then persist the read watermark —
// the next poll round won't re-mark it due to stale events. Send IPC on every selection: opening a PR is not high-frequency, and advancing the read watermark is inherently correct;
// don't rely on the side effect of the setState updater to decide "is it unread" — the updater only runs during the render phase, so synchronously reading its side effect gets no result.
Expand Down Expand Up @@ -105,9 +126,11 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) =>
setSelectedId,
selected,
refreshing,
refreshingPr,
merging,
reloadPrs,
triggerRefresh,
refreshPr,
setSelectedPrStatus,
mergeSelectedPr,
markRead,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface TitleBarProps {
discoveryFilters: readonly PrDiscoveryFilter[];
setDiscoveryFilter: (filter: PrDiscoveryFilter) => void;
viewArchived: () => void;
refreshPr: (localId: string) => void;
openPrByUrl: (url: string) => void | Promise<void>;
prStatusFilters: ReadonlyArray<{ value: FilterKey; labelKey: string }>;
setPrStatusFilter: (filter: FilterKey) => void;
Expand Down Expand Up @@ -46,6 +47,7 @@ export function TitleBar({
discoveryFilters,
setDiscoveryFilter,
viewArchived,
refreshPr,
openPrByUrl,
prStatusFilters,
setPrStatusFilter,
Expand All @@ -70,6 +72,7 @@ export function TitleBar({
discoveryFilters={discoveryFilters}
setDiscoveryFilter={setDiscoveryFilter}
viewArchived={viewArchived}
refreshPr={refreshPr}
openPrByUrl={openPrByUrl}
prStatusFilters={prStatusFilters}
setPrStatusFilter={setPrStatusFilter}
Expand Down
Loading
Loading