From 016fad1a94498ce373d5c935700e3d1b1f505a11 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Mon, 27 Jul 2026 17:02:17 +0800 Subject: [PATCH] feat(pr): F5 refreshes the current PR; move auto review to Ctrl+F5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare F5 used to kick off an auto review, which was easy to trigger by accident. Rework the PR refresh + shortcuts around a single-PR refresh: - F5 now refreshes the currently selected PR; Ctrl+F5 runs auto review (literal Ctrl on every platform — ⌘F5 collides with macOS VoiceOver; formatChord gains a `ctrl` flag so the palette hint shows ⌃ on mac). - Add a neutral (non-accent) icon refresh button to the right of the PR header's "open in browser" button; align-self:stretch matches its height. - Add a "Refresh PR" command palette entry (shown only when a PR is selected), bound to bare F5. Refresh targets a SINGLE PR, not a whole-poller tick: new prs:refreshOne IPC + refreshOnePr controller re-fetch just this PR via getSinglePullRequest, recompute localStatus from the current user's reviewer status (same rule as the poll), persist its meta, and invalidateCommentsCache (comments:changed → comments/inline-diff refetch); if the head sha advanced, best-effort sync the mirror for the diff. The one network call is this PR's fetch — usePullRequests .refreshPr then reloads the list locally (no poll of other PRs). The header button, F5, and the command all wire to it. i18n in all four locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/desktop/src/main/controllers/pr.ts | 59 +++++++++++++++++++ apps/desktop/src/main/ipc.ts | 1 + apps/desktop/src/renderer/src/App.tsx | 22 +++++-- .../command-palette/CommandPalette.tsx | 5 ++ .../features/command-palette/commands/pr.ts | 15 +++++ .../command-palette/commands/review.ts | 3 +- .../command-palette/commands/shortcuts.ts | 9 ++- .../command-palette/commands/types.ts | 2 + .../src/components/features/pr/PrHeader.tsx | 20 ++++++- .../src/components/features/pr/PrPanel.tsx | 8 +++ .../features/pr/hooks/usePullRequests.ts | 23 ++++++++ .../src/components/layout/TitleBar.tsx | 3 + .../renderer/src/hooks/useGlobalShortcuts.ts | 29 ++++++--- .../src/renderer/src/i18n/locales/de-DE.json | 2 + .../src/renderer/src/i18n/locales/en-US.json | 2 + .../src/renderer/src/i18n/locales/ja-JP.json | 2 + .../src/renderer/src/i18n/locales/zh-CN.json | 2 + .../styles/features/pr/header-actions.scss | 7 +++ packages/ipc/src/pr.ts | 10 ++++ 19 files changed, 208 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/controllers/pr.ts b/apps/desktop/src/main/controllers/pr.ts index 5ebc6452..106c6467 100644 --- a/apps/desktop/src/main/controllers/pr.ts +++ b/apps/desktop/src/main/controllers/pr.ts @@ -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). */ diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 736ace14..c01522a5 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -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) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 8bc011ba..7bd1673d 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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, @@ -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, }); @@ -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 @@ -204,6 +215,7 @@ export default function App() { prStatusFilters={visibleStatusFilters} setPrStatusFilter={setStatusFilter} viewArchived={viewArchived} + refreshPr={(localId) => void refreshPr(localId)} openPrByUrl={openPrByUrl} />
@@ -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. diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx b/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx index f07eee57..67b7c9eb 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx +++ b/apps/desktop/src/renderer/src/components/features/command-palette/CommandPalette.tsx @@ -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; /** Selectable PR status filters (used by the PR-domain "filter by category" second-level options). */ @@ -88,6 +90,7 @@ export function CommandPalette({ discoveryFilters, setDiscoveryFilter, viewArchived, + refreshPr, openPrByUrl, prStatusFilters, setPrStatusFilter, @@ -126,6 +129,7 @@ export function CommandPalette({ discoveryFilters, setDiscoveryFilter, viewArchived, + refreshPr, openPrByUrl, prStatusFilters, setPrStatusFilter, @@ -145,6 +149,7 @@ export function CommandPalette({ discoveryFilters, setDiscoveryFilter, viewArchived, + refreshPr, openPrByUrl, prStatusFilters, setPrStatusFilter, diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts index 3c4b96a2..538c0215 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/pr.ts @@ -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; } diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts index d515b9b5..fecf6ccb 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/review.ts @@ -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 }); diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts index 6c07ed22..b104f8f7 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/shortcuts.ts @@ -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)); } diff --git a/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts b/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts index 2f7d80ad..844f3cd5 100644 --- a/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts +++ b/apps/desktop/src/renderer/src/components/features/command-palette/commands/types.ts @@ -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; /** 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': {