;
/** Optional PR status filter items (pending / all / conflict / mergeable, etc., already gated by platform). */
diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx
index 4d24d41c..74c80798 100644
--- a/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx
+++ b/apps/desktop/src/renderer/src/components/features/pr/PrHeader.tsx
@@ -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';
/**
@@ -19,6 +19,8 @@ export function PrHeader({
currentUserName,
merging,
onMerge,
+ onRefresh,
+ refreshing = false,
onSetStatus,
hideLifecycle = false,
readOnly = false,
@@ -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;
@@ -106,6 +112,18 @@ export function PrHeader({
>
{t('mainPane.openInBrowser')}
+ {/* 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. */}
+
{/* 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). */}
diff --git a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
index 53b8d5da..04155f61 100644
--- a/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
+++ b/apps/desktop/src/renderer/src/components/features/pr/PrPanel.tsx
@@ -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). */
@@ -62,6 +66,8 @@ export function PrPanel({
onSetStatus,
onMerge,
merging = false,
+ onRefresh,
+ refreshing = false,
capabilities,
currentUserName,
hideLifecycle = false,
@@ -176,6 +182,8 @@ export function PrPanel({
currentUserName={currentUserName}
merging={merging}
onMerge={onMerge}
+ onRefresh={onRefresh}
+ refreshing={refreshing}
onSetStatus={onSetStatus}
hideLifecycle={hideLifecycle}
readOnly={readOnly}
diff --git a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts
index 1914c086..4a5ee78e 100644
--- a/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts
+++ b/apps/desktop/src/renderer/src/components/features/pr/hooks/usePullRequests.ts
@@ -15,6 +15,8 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) =>
const [prs, setPrs] = useState([]);
const [selectedId, setSelectedId] = useState(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);
@@ -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 => {
+ 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.
@@ -105,9 +126,11 @@ export function usePullRequests({ notifyError }: { notifyError: (msg: string) =>
setSelectedId,
selected,
refreshing,
+ refreshingPr,
merging,
reloadPrs,
triggerRefresh,
+ refreshPr,
setSelectedPrStatus,
mergeSelectedPr,
markRead,
diff --git a/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx b/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx
index 4ae5f620..83f169b6 100644
--- a/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx
+++ b/apps/desktop/src/renderer/src/components/layout/TitleBar.tsx
@@ -19,6 +19,7 @@ interface TitleBarProps {
discoveryFilters: readonly PrDiscoveryFilter[];
setDiscoveryFilter: (filter: PrDiscoveryFilter) => void;
viewArchived: () => void;
+ refreshPr: (localId: string) => void;
openPrByUrl: (url: string) => void | Promise;
prStatusFilters: ReadonlyArray<{ value: FilterKey; labelKey: string }>;
setPrStatusFilter: (filter: FilterKey) => void;
@@ -46,6 +47,7 @@ export function TitleBar({
discoveryFilters,
setDiscoveryFilter,
viewArchived,
+ refreshPr,
openPrByUrl,
prStatusFilters,
setPrStatusFilter,
@@ -70,6 +72,7 @@ export function TitleBar({
discoveryFilters={discoveryFilters}
setDiscoveryFilter={setDiscoveryFilter}
viewArchived={viewArchived}
+ refreshPr={refreshPr}
openPrByUrl={openPrByUrl}
prStatusFilters={prStatusFilters}
setPrStatusFilter={setPrStatusFilter}
diff --git a/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts b/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts
index 1fa9d499..9f31152c 100644
--- a/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts
+++ b/apps/desktop/src/renderer/src/hooks/useGlobalShortcuts.ts
@@ -4,8 +4,11 @@ import { chatRunStore } from '../stores/chat-run-store';
/**
* Window-level global shortcuts (VS Code style), all attached to a single `keydown` listener here:
- * - **F5**: run auto review on the currently selected PR (same logic as the command palette: only triggers when there
- * is a selected PR, it's engageable, and it's not already running — reentrancy guard).
+ * - **F5**: refresh the currently selected PR (re-fetch just that PR from remote, not a whole-poller tick). Bare,
+ * universally-understood "refresh"; no-op when nothing is selected.
+ * - **Ctrl+F5** (literal Ctrl on every platform — ⌘F5 would collide with macOS VoiceOver): run auto review on the
+ * currently selected PR (moved off bare F5 to avoid accidental triggers; same guards as the command palette: a PR is
+ * selected, it's engageable, and it's not already running).
* - **DevTools**: mac ⌥⌘I / otherwise Ctrl+Shift+I (with Shift/Alt, distinguished from the single-modifier B/J below).
* - **View closed**: mac ⌘⇧H (avoiding the system "Hide App" ⌘H) / otherwise Ctrl+H (browser history convention).
* - **Layout toggles**: Ctrl/Cmd+B toggles the PR list (left sidebar), Ctrl/Cmd+J toggles the chat panel (right);
@@ -18,6 +21,7 @@ export function useGlobalShortcuts({
selectedId,
canEngage,
viewArchived,
+ refreshPr,
setSidebarCollapsed,
setChatCollapsed,
}: {
@@ -25,25 +29,36 @@ export function useGlobalShortcuts({
selectedId: string | null;
canEngage: boolean;
viewArchived: () => void;
+ /** Refresh a single PR by localId (re-fetch that PR from remote); bound to bare F5 for the selected PR. */
+ refreshPr: (localId: string) => void;
setSidebarCollapsed: Dispatch>;
setChatCollapsed: Dispatch>;
}): void {
- // Refs for the selected PR / engageable state: let the stable listener read live values, avoiding a resubscribe on every PR switch.
+ // Refs for the selected PR / engageable state / refresh: let the stable listener read live values, avoiding a resubscribe on every PR switch / refresh-fn identity change.
const selectedIdRef = useRef(selectedId);
selectedIdRef.current = selectedId;
const canEngageRef = useRef(canEngage);
canEngageRef.current = canEngage;
+ const refreshPrRef = useRef(refreshPr);
+ refreshPrRef.current = refreshPr;
useEffect(() => {
const isMac = platform === 'darwin';
const onKey = (e: KeyboardEvent): void => {
const k = e.key.toLowerCase();
- // F5: run auto review on the currently selected PR (only triggers when there is a selected PR, it's engageable, and it's not already running — reentrancy guard)
+ // F5: bare = refresh the selected PR; Ctrl+F5 = run auto review on it (guards: selected + engageable + not already
+ // running). Always preventDefault so the Electron renderer never hard-reloads on F5 / Ctrl+F5.
if (k === 'f5') {
+ e.preventDefault();
const id = selectedIdRef.current;
- if (id && canEngageRef.current && !chatRunStore.getSnapshot().agentPrs.includes(id)) {
- e.preventDefault();
- void invoke('agent:run', { localId: id });
+ const onlyCtrl = e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
+ const noMods = !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey;
+ if (onlyCtrl) {
+ if (id && canEngageRef.current && !chatRunStore.getSnapshot().agentPrs.includes(id)) {
+ void invoke('agent:run', { localId: id });
+ }
+ } else if (noMods && id) {
+ refreshPrRef.current(id);
}
return;
}
diff --git a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
index f961ceec..4526a29e 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
@@ -201,6 +201,7 @@
"cmdOpenDevtools": "DevTools öffnen",
"cmdOpenPrUrl": "URL öffnen (aktuelle Plattform)",
"cmdOpenSettings": "Einstellungen öffnen",
+ "cmdRefreshPr": "PR aktualisieren",
"cmdRunAutoReview": "Auto-Review ausführen",
"cmdSwitchLanguage": "Anzeigesprache wechseln",
"cmdSwitchModel": "Modell wechseln",
@@ -569,6 +570,7 @@
"ownPrReason": "Sie können Ihren eigenen PR nicht reviewen",
"publishComments": "Kommentare absenden ({{n}})",
"publishCommentsTitle": "{{n}} Entwürfe gesammelt auf dem Remote veröffentlichen",
+ "refreshTitle": "Aktualisieren",
"showBlame": "Blame aktivieren (nur head-Seite)",
"showWhitespace": "Leerzeichen anzeigen (Leerzeichen / Tab)",
"sideBySide": "Nebeneinander",
diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US.json b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
index 35cab87f..849f40a9 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
@@ -201,6 +201,7 @@
"cmdOpenDevtools": "Open DevTools",
"cmdOpenPrUrl": "Open URL (current platform)",
"cmdOpenSettings": "Open Settings",
+ "cmdRefreshPr": "Refresh PR",
"cmdRunAutoReview": "Run Auto Review",
"cmdSwitchLanguage": "Switch Display Language",
"cmdSwitchModel": "Switch Model",
@@ -569,6 +570,7 @@
"ownPrReason": "You can't review your own PR",
"publishComments": "Submit comments ({{n}})",
"publishCommentsTitle": "Publish {{n}} drafts to the remote in bulk",
+ "refreshTitle": "Refresh",
"showBlame": "Enable blame (head side only)",
"showWhitespace": "Show whitespace (spaces / tabs)",
"sideBySide": "Side by side",
diff --git a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
index 9b6452f7..de8d4f33 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
@@ -201,6 +201,7 @@
"cmdOpenDevtools": "DevTools を開く",
"cmdOpenPrUrl": "URL で開く(現在のプラットフォーム)",
"cmdOpenSettings": "設定を開く",
+ "cmdRefreshPr": "PR を更新",
"cmdRunAutoReview": "自動レビューを実行",
"cmdSwitchLanguage": "表示言語を切り替え",
"cmdSwitchModel": "モデルを切り替え",
@@ -555,6 +556,7 @@
"ownPrReason": "自分の PR はレビューできません",
"publishComments": "コメントを投稿 ({{n}})",
"publishCommentsTitle": "{{n}} 件の下書きをリモートに一括で公開します",
+ "refreshTitle": "更新",
"showBlame": "Blame を有効化(head 側のみ)",
"showWhitespace": "空白文字を表示(スペース / Tab)",
"sideBySide": "並べて表示",
diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
index 05c53c5d..c8d2d07d 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
@@ -201,6 +201,7 @@
"cmdOpenDevtools": "打开 DevTools",
"cmdOpenPrUrl": "打开 URL(当前平台)",
"cmdOpenSettings": "打开设置",
+ "cmdRefreshPr": "刷新 PR",
"cmdRunAutoReview": "运行自动评审",
"cmdSwitchLanguage": "切换显示语言",
"cmdSwitchModel": "切换模型",
@@ -555,6 +556,7 @@
"ownPrReason": "不能审批自己的 PR",
"publishComments": "提交评论 ({{n}})",
"publishCommentsTitle": "批量发布 {{n}} 条草稿到远端",
+ "refreshTitle": "刷新",
"showBlame": "开启追溯显示(仅 head 侧)",
"showWhitespace": "显示空白字符(空格 / Tab)",
"sideBySide": "并排",
diff --git a/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss b/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss
index e29367c0..b3bd6777 100644
--- a/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss
+++ b/apps/desktop/src/renderer/src/styles/features/pr/header-actions.scss
@@ -16,6 +16,13 @@
gap: $space-2;
}
+// Refresh: neutral icon-only button beside "open in browser". Its content is a single icon (shorter than the text
+// buttons' line-height), so stretch it to the row height to match the open-in-browser / decision buttons; the icon
+// stays vertically centered (.btn is align-items:center).
+.pr-header-refresh {
+ align-self: stretch;
+}
+
// "Merge" button: branch-merge icon + label. Base state matches approve's inactive (default border + body color, only the icon uses
// the green semantic color), no longer "always-green filled" — always-green is easily misread as a "clicked / merged" state. Instead, a 1s-cycle blink highlights
// clickability; after clicking (merging → disabled) it goes through .btn:disabled to gray out and stop blinking.
diff --git a/packages/ipc/src/pr.ts b/packages/ipc/src/pr.ts
index 4ceebad4..d157c278 100644
--- a/packages/ipc/src/pr.ts
+++ b/packages/ipc/src/pr.ts
@@ -125,6 +125,16 @@ export interface PrChannels {
};
};
'prs:refresh': { request: void; response: PollResult };
+ /**
+ * Refresh a SINGLE PR from remote (metadata + comments) without a whole-poller tick: re-fetch just this PR, recompute
+ * localStatus from the current user's reviewer status, persist its meta, and invalidate its comments cache (broadcasts
+ * comments:changed). Returns the updated PR (null if not found / not fetchable). Used by the header refresh button, the
+ * F5 shortcut, and the "Refresh PR" command.
+ */
+ 'prs:refreshOne': {
+ request: { localId: string };
+ response: StoredPullRequest | null;
+ };
/** Poller's last completion time (ISO or null); used for initialization at startup */
'prs:lastSync': { request: void; response: { at: string | null } };
'prs:setLocalStatus': {