diff --git a/CHANGELOG.md b/CHANGELOG.md index 95f4fe7f..a487e0b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project are recorded here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versioning follows [Semantic Versioning](https://semver.org/). +## [0.11.2] - 2026-07-28 + +> Highlights of this release: +> +> - **Stale-review marker**: when a PR gets new commits, the review timeline draws a divider so reviews made against older code are clearly separated from newer ones — hover to see the commit. +> - **Commit-aware review**: continuing a review conversation after new commits land — the agent now knows earlier discussion may reference code that has since changed. +> - **Quick single-PR refresh**: refresh just the current PR from remote with F5, a new refresh button, or a command — instead of waiting for the periodic sync. +> +> Plus fixes for inline-comment editing being interrupted by a refresh, comment positioning when a narrow diff collapses to unified, and an unexpected macOS media-library prompt. + +### ✨ Added + +- The review timeline now marks a divider whenever the PR gains new commits, separating reviews made against older code from newer ones (hover shows the commit's message), so it's obvious when earlier review results are based on outdated code. +- The review agent is now aware of code changes across commits: when you continue a conversation after new commits land, it knows earlier discussion may reference code that has since changed. +- You can now refresh just the current PR from remote — press F5, click the new refresh button next to "open in browser", or run the "Refresh PR" command — instead of waiting for the periodic background sync. + +### ♻️ Changed + +- Auto review moved from F5 to Ctrl+F5 to avoid triggering it by accident; F5 now refreshes the current PR. + +### 🔧 Fixed + +- An in-progress inline comment or draft editor in the diff is no longer interrupted when the comment list refreshes — whether from the periodic sync or a new remote comment arriving while you're typing. +- Inline comments and their markers now appear in the right place when a side-by-side diff automatically switches to unified because the pane is too narrow (comments anchored to the deleted / base side previously went missing); jumping to such a comment's anchor also lands on the correct line. +- On macOS, the app no longer prompts for access to Apple Music / your media library on launch. + ## [0.11.1] - 2026-07-14 > Highlights of this release: @@ -488,6 +514,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/). License: [Apache-2.0](LICENSE). The package bundles third-party components (pr-agent, Electron, etc.), each distributed under its own license, see [NOTICE](NOTICE). [Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.1...HEAD +[0.11.2]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.1...v0.11.2 [0.11.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 diff --git a/CHANGELOG.zh-CN.md b/CHANGELOG.zh-CN.md index f3bd38cf..7b3a085e 100644 --- a/CHANGELOG.zh-CN.md +++ b/CHANGELOG.zh-CN.md @@ -5,6 +5,32 @@ 本项目所有重要变更记录于此。格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/), 版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [0.11.2] - 2026-07-28 + +> 本次发布要点: +> +> - **评审过时标记**:当 PR 有新提交进来时,评审时间线会画一条分割线,把基于旧代码的评审与新代码的评审清晰分隔——悬停可见对应提交。 +> - **感知提交变化的评审**:新提交进来后继续评审对话时,Agent 现在知道先前的讨论可能引用了已变更的代码。 +> - **快速刷新单个 PR**:用 F5、新增的刷新按钮或命令,只从远端刷新当前 PR,无需等待周期性同步。 +> +> 另修复:刷新时正在编写的内联评论被打断、窄面板下 side-by-side 折叠为 unified 时的评论定位、以及 macOS 上意外的媒体库权限申请。 + +### ✨ 新增 + +- 每当 PR 有新提交进来,评审时间线会标出一条分割线,将基于旧代码的评审与新代码的评审分隔开(悬停显示该提交的消息),让「先前评审结果基于过时代码」一目了然。 +- 评审 Agent 现在能感知跨提交的代码变化:新提交进来后继续对话时,它知道先前的讨论可能引用了此后已变更的代码。 +- 现在可只从远端刷新当前 PR——按 F5、点击「浏览器打开」旁新增的刷新按钮,或执行「刷新 PR」命令——无需等待周期性后台同步。 + +### ♻️ 变更 + +- 自动评审从 F5 改为 Ctrl+F5,避免误触;F5 现在用于刷新当前 PR。 + +### 🔧 修复 + +- diff 中正在编写的内联评论或草稿编辑,在评论列表刷新时不再被打断——无论是周期性同步,还是编写时有新的远端评论到达。 +- 当面板过窄导致 side-by-side diff 自动切换为 unified 时,内联评论及其标记现在显示在正确位置(此前锚定在删除 / 基准侧的评论会丢失);跳转到这类评论的锚点也能定位到正确行。 +- macOS 上,应用启动时不再申请 Apple Music / 媒体库访问权限。 + ## [0.11.1] - 2026-07-14 > 本次发布要点: @@ -488,6 +514,7 @@ 许可证:[Apache-2.0](LICENSE)。打包内含第三方组件(pr-agent、Electron 等),各按其许可证分发,见 [NOTICE](NOTICE)。 [Unreleased]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.1...HEAD +[0.11.2]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.1...v0.11.2 [0.11.1]: https://github.com/huhamhire/code-meeseeks/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/huhamhire/code-meeseeks/compare/v0.9.0...v0.10.0 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d21fbfee..e5e8f328 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@meebox/desktop", - "version": "0.11.1", + "version": "0.11.2", "private": true, "description": "meebox Electron desktop app", "author": { diff --git a/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts b/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts index f92b1007..a37fb752 100644 --- a/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts +++ b/apps/desktop/src/main/bootstrap/os-startup-tweaks.ts @@ -56,10 +56,16 @@ function applyWindowsStartupTweaks(): void { * real keychain. Cost: cookie encryption degrades to a static key, but the key was already stored in * plaintext, so no real loss. Removable once there's a proper Developer ID signature. Must be before * app.whenReady(). + * - disable-features MediaSessionService/HardwareMediaKeyHandling: Chromium's macOS "Now Playing" / + * media-session integration queries the MediaPlayer framework, which makes macOS prompt for "access + * Apple Music / your media library" on launch. We play no media and expose no now-playing controls, so + * this permission is unexpected and confusing; disabling the features stops Chromium from ever touching + * the media library. Must be before app.whenReady(). * - Prepend common CLI dirs to PATH (see augmentMacPath): must be before pr-agent probing / running. */ function applyMacStartupTweaks(): void { app.commandLine.appendSwitch('use-mock-keychain'); + app.commandLine.appendSwitch('disable-features', 'MediaSessionService,HardwareMediaKeyHandling'); augmentMacPath(); } 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/main/services/agent/planning.ts b/apps/desktop/src/main/services/agent/planning.ts index ef420bc8..5e00a117 100644 --- a/apps/desktop/src/main/services/agent/planning.ts +++ b/apps/desktop/src/main/services/agent/planning.ts @@ -120,7 +120,13 @@ export async function runPlanning( deps.stateStore, pr.localId, // When a Diff selection reference is present, persist it alongside, for the UI to show the "referenced code" collapsed below the bubble. - { role: 'user', content: userRequest, referencedContext: deps.referencedContext }, + // headSha stamps the PR head this turn was made against, so a later turn on a newer commit gets a "code changed" marker in the planning context. + { + role: 'user', + content: userRequest, + referencedContext: deps.referencedContext, + headSha: pr.sourceRef.sha, + }, now, ); @@ -161,6 +167,8 @@ export async function runPlanning( summarySections: buildSummarySections(), userRequest, history, + // Current PR head, so the planning context can flag a code change since the newest historical turn. + currentHeadSha: pr.sourceRef.sha, referencedContext: deps.referencedContext, maxSteps: deps.maxSteps, maxFollowupAsks: deps.maxFollowupAsks, @@ -172,7 +180,12 @@ export async function runPlanning( await appendAgentMessage( deps.stateStore, pr.localId, - { role: 'assistant', content: result.finalText, recommendation: result.recommendation }, + { + role: 'assistant', + content: result.finalText, + recommendation: result.recommendation, + headSha: pr.sourceRef.sha, + }, now, ); } diff --git a/apps/desktop/src/main/services/notifications.ts b/apps/desktop/src/main/services/notifications.ts index 29865c7e..6f947dd4 100644 --- a/apps/desktop/src/main/services/notifications.ts +++ b/apps/desktop/src/main/services/notifications.ts @@ -67,7 +67,11 @@ function activateOnClick(e: PollNotificationEvent): () => void { anchor: // File-level comments (no line) aren't line-navigable → no anchor (clicking just opens the PR). e.comment?.anchor && e.comment.anchor.line != null - ? { path: e.comment.anchor.path, line: e.comment.anchor.line } + ? { + path: e.comment.anchor.path, + line: e.comment.anchor.line, + side: e.comment.anchor.side, + } : null, }); }; diff --git a/apps/desktop/src/main/services/pr-agent/run-executor.ts b/apps/desktop/src/main/services/pr-agent/run-executor.ts index c7e28658..398e0234 100644 --- a/apps/desktop/src/main/services/pr-agent/run-executor.ts +++ b/apps/desktop/src/main/services/pr-agent/run-executor.ts @@ -265,6 +265,8 @@ export class RunExecutor { origin: item.priority, // Single-commit review scope persisted with the run: the result card uses it to show a scope badge. scope: req.scope, + // PR head commit the run runs against: stamped so ChatPane can draw a commit divider when the code changes between runs. + headSha: pr.sourceRef.sha, }); // Upgrade the info (startedAt=null at enqueue) to active form + broadcast (via the scheduling layer). item.info = { ...item.info, startedAt: run.startedAt }; 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/chat/ChatPane.tsx b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx index 3399984b..8fcd5b69 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx @@ -1,13 +1,15 @@ -import { useEffect, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import type { Finding, LocalPrStatus, PrAgentStatus, + PrCommit, ReviewRun, ReviewRunCommitScope, StoredPullRequest, } from '@meebox/shared'; +import { invoke } from '../../../api'; import { ChatIcon, TrashIcon, ConfirmModal, PaneLoading } from '../../common'; import { useChatRunStore } from '../../../stores/chat-run-store'; import { useDraftsForPr } from '../../../stores/drafts-store'; @@ -24,6 +26,7 @@ import { useChatActions } from './hooks/useChatActions'; import { useChatTimeline } from './hooks/useChatTimeline'; import { AgentStepRow, ThinkingLive } from './components/AgentStep'; import { ChatEmpty } from './components/ChatEmpty'; +import { CommitDivider } from './components/CommitDivider'; import { ChatInputBar } from './components/ChatInputBar'; import { ConversationMessage } from './components/ConversationMessage'; import { PlanPanel } from './components/PlanPanel'; @@ -241,12 +244,55 @@ export function ChatPane({ prLocalId, }); + // Commit dividers: mark every point in the run timeline where the reviewed commit changes, so the boundary persists + // rather than vanishing once the new code is reviewed. Two cases: + // - between two consecutive runs whose headSha differs → a divider *before* the newer run (a durable boundary + // between the old-commit runs above and the new-commit runs below); + // - a trailing divider at the bottom when the current PR head has advanced past the last run's commit (covers a new + // commit that hasn't been reviewed yet — including while a run against it is still in flight). + // The timeline is ascending by start time; only runs that recorded a headSha participate (pre-feature runs are skipped). + const commitDividers = useMemo(() => { + const before = new Map(); // timeline entry.key → the newer headSha to render a divider before it + let prevSha: string | undefined; + for (const entry of timeline) { + const sha = entry.run?.headSha; + if (!sha) continue; + if (prevSha && sha !== prevSha) before.set(entry.key, sha); + prevSha = sha; + } + const head = pr?.sourceRef.sha; + const bottom = head && prevSha && head !== prevSha ? head : null; + return { before, bottom }; + }, [timeline, pr?.sourceRef.sha]); + + // Commit messages for divider tooltips: fetch the PR's commits (main-cached; keyed on head sha so it refreshes when + // the head advances) into a sha → message map. Empty until loaded / on failure (the tooltip falls back to the short sha). + const [commitMsgBySha, setCommitMsgBySha] = useState>(() => new Map()); + useEffect(() => { + if (!prLocalId) { + setCommitMsgBySha(new Map()); + return; + } + let cancelled = false; + void invoke('diff:listCommits', { localId: prLocalId }) + .then((commits: PrCommit[]) => { + if (!cancelled) setCommitMsgBySha(new Map(commits.map((c) => [c.sha, c.message]))); + }) + .catch(() => { + if (!cancelled) setCommitMsgBySha(new Map()); + }); + return () => { + cancelled = true; + }; + }, [prLocalId, pr?.sourceRef.sha]); + // Pure UI state: rule preview modal / clear confirm modal / merge confirm modal const [showRulePreview, setShowRulePreview] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false); const [showMergeConfirm, setShowMergeConfirm] = useState(false); - const { runs, error, loadingSession, matchedRules, bodyRef, hasMoreOlder, loadingOlder } = session; + const { runs, error, loadingSession, matchedRules, bodyRef, hasMoreOlder, loadingOlder } = + session; // Re-review card ↔ original finding card cross-link: scroll to and briefly highlight. The flash class differs by target: run cards use chat-run-flash // (fading background, visible on the run card's transparent base); finding cards use chat-finding-flash (an overlay highlight ring — finding cards have @@ -355,26 +401,35 @@ export function ChatPane({ Initially only the latest RUNS_PAGE_SIZE are fetched; after scrolling up to the top, fetch an earlier batch by cursor */} {timeline.map((entry, i) => entry.run ? ( + // A commit divider precedes this run when the reviewed commit changed since the previous run (see commitDividers). // data-run-id: for re-review card ↔ original finding card cross-link scroll targeting (scrollToRun). -
- -
+ + {commitDividers.before.has(entry.key) && ( + + )} +
+ +
+
) : entry.active ? ( // Running: progress bar + live stdout stream, interleaved into the timeline by start time (startedAt is null when enqueued, // set when it starts, falling back to enqueuedAt). Not rendered when prAgent is not ready. @@ -396,6 +451,15 @@ export function ChatPane({ ) : null, )} + {/* Bottom commit divider: the PR head advanced past the last run's commit and no run against it exists yet + (a new commit not reviewed yet, including while a run against it is still in flight). Once such a run + completes, the boundary instead renders between the old and new runs above (see commitDividers.before). */} + {commitDividers.bottom && ( + + )} {/* This PR's queued tasks: placed after running ones, each cancellable individually. The position uses the **global** queue order (the queue is shared across PRs, otherwise every PR showing "position 1" would be misleading) — the runId's index in the global waiting array +1. */} {myWaiting.map((w) => ( diff --git a/apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx b/apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx new file mode 100644 index 00000000..93917e83 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx @@ -0,0 +1,27 @@ +import { CommitIcon } from '../../../common'; + +/** + * Sawtooth "commit divider" shown at the bottom of the run timeline when the PR head has advanced past the commit the + * most recent run reviewed (see ChatPane staleHeadSha). It marks the new head — signalling that the reviews above are + * now based on stale code — even if no run has been started against the new commit yet. The chip shows the abbreviated + * commit id; the tooltip shows that commit's message (falls back to the short sha when unavailable). Reuses the same + * chip vocabulary as the single-commit scope badge in RunResultView. + */ +export function CommitDivider({ sha, message }: { sha: string; message?: string }) { + const short = sha.slice(0, 8); + // Tooltip = the actual commit message (more meaningful than a generic "code changed" string, and needs no i18n). + const title = message?.trim() || short; + return ( +
+
+ ); +} 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 4e58b7e5..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). */ @@ -37,13 +41,13 @@ export interface PrPanelProps { pendingDiffNav?: { runId?: string; findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; + anchor: { path: string; startLine: number; endLine: number; side?: 'old' | 'new' }; } | null; onDiffNavConsumed?: () => void; onRequestDiffNav?: (target: { runId?: string; findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; + anchor: { path: string; startLine: number; endLine: number; side?: 'old' | 'new' }; }) => void; /** External request to switch to a given tab (e.g. clicking a summary comment notification → 'activity'); cleared via onPendingTabConsumed after consumption. */ pendingTab?: PrTab | null; @@ -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} @@ -233,7 +241,7 @@ export function PrPanel({ // File-level anchors (no line) aren't line-navigable; CommentItem doesn't make them clickable, guard anyway. if (a.line == null) return; onRequestDiffNav?.({ - anchor: { path: a.path, startLine: a.line, endLine: a.line }, + anchor: { path: a.path, startLine: a.line, endLine: a.line, side: a.side }, }); }} /> @@ -252,6 +260,7 @@ export function PrPanel({ path: d.anchor.path, startLine: d.anchor.startLine, endLine: d.anchor.endLine, + side: d.anchor.side, }, }); }} @@ -281,6 +290,7 @@ export function PrPanel({ path: d.anchor.path, startLine: d.anchor.startLine, endLine: d.anchor.endLine, + side: d.anchor.side, }, }); }} 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/features/pr/tabs/comments/CommentItem.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx index 858a436e..7251b145 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/comments/CommentItem.tsx @@ -22,43 +22,9 @@ const InlineCodeContext = lazy(() => import('./InlineCodeContext').then((m) => ({ default: m.InlineCodeContext })), ); -/** - * Structural equality comparison of the comment tree (by remoteId + body + version + edit/delete permissions + recursive replies). poll mostly returns - * comments with unchanged content: on equality, skip setState and keep the old reference so React bails out, avoiding pointless re-render of the whole - * comment tree (including inline Monaco) (refresh flicker). - */ -export function sameCommentList(a: readonly PrComment[], b: readonly PrComment[]): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - const x = a[i]!; - const y = b[i]!; - if ( - x.remoteId !== y.remoteId || - x.body !== y.body || - x.version !== y.version || - x.canEdit !== y.canEdit || - x.canDelete !== y.canDelete || - !sameReactions(x.reactions, y.reactions) || - !sameCommentList(x.replies, y.replies) - ) { - return false; - } - } - return true; -} - -/** Equality comparison of the reactions array (emoji + count + mine triple matching item by item): lets reaction changes after a toggle trigger a re-render. */ -function sameReactions(a: PrComment['reactions'], b: PrComment['reactions']): boolean { - const x = a ?? []; - const y = b ?? []; - if (x.length !== y.length) return false; - for (let i = 0; i < x.length; i++) { - if (x[i]!.emoji !== y[i]!.emoji || x[i]!.count !== y[i]!.count || x[i]!.mine !== y[i]!.mine) { - return false; - } - } - return true; -} +// Structural comment-tree equality lives in the shared module so the diff view (useDiffComments) can reuse the same poll bail; re-exported here for the +// activity/comments-page importers that historically pulled it from this file. +export { sameCommentList } from '../shared/commentEquality'; /** * Maximum indent level for nested replies: past this level recursion continues but **no further indent is added** (flattened display), avoiding diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx index 8e11a08f..85a6eda0 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DiffView.tsx @@ -21,6 +21,7 @@ import { BackendErrorBanner, BackendErrorView, SyncProgress } from './DiffStatus import { BlameColumn } from './blame/BlameColumn'; import { fileKey, type PendingCommitView } from './diff-types'; import { + useActualRenderSideBySide, useBlame, useChangedFiles, useCommentZones, @@ -60,7 +61,7 @@ interface DiffViewProps { pendingNav?: { runId?: string; findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; + anchor: { path: string; startLine: number; endLine: number; side?: 'old' | 'new' }; } | null; onNavConsumed?: () => void; /** @@ -107,6 +108,10 @@ export function DiffView({ const drafts = useDraftsForPr(pr.localId); // Use state, not a ref: onMount fires asynchronously, so a state change is required to re-run the subsequent useEffect decoration logic. const [diffEditor, setDiffEditor] = useState(null); + // The ACTUAL render mode: renderSideBySide is only the toolbar intent, but Monaco auto-degrades to inline when the + // pane is too narrow. Positioning (which inner editor a comment/draft zone, glyph, or "+" adder targets) must use + // this, not the intent, or old-side items land on the original editor Monaco has hidden. See useActualRenderSideBySide. + const actualSideBySide = useActualRenderSideBySide(diffEditor, renderSideBySide); const progress = useSyncProgress(pr); const { fileListWidth, startFileListResize } = useFileListWidth(); @@ -168,9 +173,16 @@ export function DiffView({ pendingNav, onNavConsumed, triggerAutoEdit, + // Intent prop: useDiffNav reads the actual mode live at reveal time to place old-side targets correctly. + renderSideBySide, }); // Capture the Diff selection → selectionStore, so ChatPane can carry the selected code as implicit context into agent/ask questions. - useSelectionCapture({ diffEditor, selected, prLocalId: pr.localId, renderSideBySide }); + useSelectionCapture({ + diffEditor, + selected, + prLocalId: pr.localId, + renderSideBySide: actualSideBySide, + }); // sidebar mode: 'tree' (file tree) / 'search' (cross-file search), defaults to the file tree. Returns to 'tree' on PR switch. const [sidebarMode, setSidebarMode] = useState<'tree' | 'search'>('tree'); @@ -237,7 +249,9 @@ export function DiffView({ attachmentBase, prLocalId: pr.localId, prWebUrl: pr.url, - renderSideBySide, + // The actual render mode, so old-side comment zones + glyph/tick markers land on the visible editor after an + // auto-degrade to unified (the reported "published comment lacks position" case). + renderSideBySide: actualSideBySide, commentHardBreaks, reactionsMode, attachmentsEnabled, @@ -254,7 +268,7 @@ export function DiffView({ selected, prLocalId: pr.localId, registerEditTrigger, - renderSideBySide, + renderSideBySide: actualSideBySide, commentHardBreaks, attachmentsEnabled, mentionCandidates, @@ -271,7 +285,8 @@ export function DiffView({ prLocalId: pr.localId, platform: pr.platform, scopeKind: scope.kind, - renderSideBySide, + // Actual mode: wire the old-side "+" adder only when the original editor is actually visible (not auto-degraded). + renderSideBySide: actualSideBySide, readOnly, triggerAutoEdit, t, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx index f60adc55..035aaead 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/DraftZoneList.tsx @@ -7,8 +7,10 @@ import { DraftZone } from '../drafts/DraftZone'; /** * Container for multiple drafts on the same line; each is an independent DraftZone (maintaining its own read/edit), separated by hr. * onSave / onDelete call IPC drafts:update / drafts:delete here; after writing to disk the main side - * broadcasts a drafts:changed event → drafts-store refetches → DiffView's top-level useEffect rebuilds the - * zones (this component unmounts/remounts along with it). + * broadcasts a drafts:changed event → drafts-store refetches → useDraftZones' content effect calls the zone + * controller's update(), which **reconciles** this zone in place (keyed by draft id) rather than unmounting it, so a + * DraftZone whose editor is open keeps its text / focus across the refresh. Only a removed draft (deleted / published) + * unmounts. */ export function DraftZoneList({ drafts, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts index b5074e68..46b67b2c 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/index.ts @@ -11,6 +11,10 @@ export { useBlame, type BlameState } from './useBlame'; export { useDraftAutoEdit, type DraftAutoEdit } from './useDraftAutoEdit'; export { useDiffNav, type PendingNav, type PendingScroll } from './useDiffNav'; export { useCommentZones } from './useCommentZones'; +export { + useActualRenderSideBySide, + isActualSideBySide, +} from './useActualRenderSideBySide'; export { useDiffOverviewMarks } from './useDiffOverviewMarks'; export { useDraftZones } from './useDraftZones'; export { useLineCommentAdder } from './useLineCommentAdder'; diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useActualRenderSideBySide.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useActualRenderSideBySide.ts new file mode 100644 index 00000000..2e42295e --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useActualRenderSideBySide.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; +import { type editor as MonacoEditor } from 'monaco-editor'; + +/** + * `renderSideBySide` on the toolbar is the user's *intent* (persisted toggle). Monaco auto-degrades side-by-side → + * inline when the pane is too narrow (`useInlineViewWhenSpaceIsLimited`, on by default), leaving the intent `true` + * while the actual layout is unified. Monaco reflects the real mode in the `.monaco-diff-editor` root node's + * `side-by-side` class (removed when downgrading to inline). + * + * Anything that positions content by editor side — which inner editor a comment/draft zone, glyph dot, overview tick, + * or reveal targets — must key off the **actual** mode, not the intent: in the auto-degraded state the original editor + * is hidden, so an old-side item routed there by intent lands on an invisible editor ("missing position"). This + * returns the actual mode: the intent AND the class being present. + */ +export function isActualSideBySide( + diffEditor: MonacoEditor.IStandaloneDiffEditor, + renderSideBySide: boolean, +): boolean { + if (!renderSideBySide) return false; + const el = diffEditor.getContainerDomNode().querySelector('.monaco-diff-editor'); + return el ? el.classList.contains('side-by-side') : true; +} + +/** + * Reactive {@link isActualSideBySide}: recomputes when Monaco's layout crosses the side-by-side ↔ inline breakpoint + * (`onDidLayoutChange`) or the diff recomputes (`onDidUpdateDiff`). rAF-coalesced and deferred so the class is read + * after Monaco has switched it (the layout event may precede the class flip). Returns the intent prop directly until + * the editor is available. + */ +export function useActualRenderSideBySide( + diffEditor: MonacoEditor.IStandaloneDiffEditor | null, + renderSideBySide: boolean, +): boolean { + const [actual, setActual] = useState(renderSideBySide); + useEffect(() => { + if (!diffEditor) { + setActual(renderSideBySide); + return; + } + const read = (): void => setActual(isActualSideBySide(diffEditor, renderSideBySide)); + read(); + let raf = 0; + const schedule = (): void => { + if (raf) return; + raf = requestAnimationFrame(() => { + raf = 0; + read(); + }); + }; + // The original editor still emits layout changes as it collapses to / expands from hidden at the breakpoint; + // onDidUpdateDiff covers the first async diff settling after a file switch. + const layoutDisp = diffEditor.getOriginalEditor().onDidLayoutChange(schedule); + const diffDisp = diffEditor.onDidUpdateDiff(schedule); + return () => { + if (raf) cancelAnimationFrame(raf); + layoutDisp.dispose(); + diffDisp.dispose(); + }; + }, [diffEditor, renderSideBySide]); + return actual; +} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx index d191c8fa..6157d406 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useCommentZones.tsx @@ -8,6 +8,7 @@ import { renderHoverMd, } from '../inline-comments/InlineCommentZone'; import { createInlineZones, type InlineZonesController } from '../zones/mountInlineZones'; +import { remapOldByLineToModified } from '../zones/line-mapping'; import type { LoadedContent } from '../diff-types'; /** @@ -158,16 +159,29 @@ export function useCommentZones(opts: { const originalEditor = diffEditor.getOriginalEditor(); const modifiedEditor = diffEditor.getModifiedEditor(); - const originalDecorations = originalEditor.createDecorationsCollection( - buildDecorations(oldByLine), - ); + // Old-side markers: in side-by-side they sit on the (visible) original editor; in unified — including + // auto-degraded-from-side-by-side, since renderSideBySide here is the ACTUAL render mode — the original editor is + // hidden, so remap them onto the modified editor at the mapped line (mirroring how the zone bodies are routed in + // computeDesired), otherwise the glyph dot + overview tick vanish with the hidden editor. + const modifiedLines = new Map(newByLine); + let originalLines: Map | null = oldByLine; + if (!renderSideBySide && oldByLine.size > 0) { + originalLines = null; + const remappedOld = remapOldByLineToModified(diffEditor.getLineChanges() ?? [], oldByLine); + for (const [line, cs] of remappedOld) { + modifiedLines.set(line, [...(modifiedLines.get(line) ?? []), ...cs]); + } + } + const originalDecorations = originalLines + ? originalEditor.createDecorationsCollection(buildDecorations(originalLines)) + : null; const modifiedDecorations = modifiedEditor.createDecorationsCollection( - buildDecorations(newByLine), + buildDecorations(modifiedLines), ); return () => { try { - originalDecorations.clear(); + originalDecorations?.clear(); modifiedDecorations.clear(); } catch { // editor already disposed diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts index 46487f48..533ab094 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffComments.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import type { PrComment, StoredPullRequest } from '@meebox/shared'; import { invoke, subscribe } from '../../../../../../api'; import { formatBackendError, type FormattedError } from '../../../../../../errors'; +import { sameCommentList } from '../../shared/commentEquality'; export interface DiffCommentsState { comments: PrComment[]; @@ -37,7 +38,9 @@ export function useDiffComments( const fetchList = (force: boolean): void => { invoke('diff:listComments', { localId: pr.localId, force }) .then((cs) => { - if (!cancelled) setComments(cs); + // poll mostly returns unchanged comments: keep the old array reference on structural equality so downstream memos + // (mentionCandidates) stay identity-stable and the inline draft/comment zones aren't torn down mid-edit (see useDraftZones). + if (!cancelled) setComments((prev) => (sameCommentList(prev, cs) ? prev : cs)); }) .catch((e: unknown) => { if (!cancelled) { diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts index 11d8214d..d9c6bb6b 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffNav.ts @@ -3,6 +3,8 @@ import { type editor as MonacoEditor } from 'monaco-editor'; import type { ReviewDraft } from '@meebox/shared'; import type { DiffChangedFile } from '@meebox/ipc'; import { fileKey, type LoadedContent } from '../diff-types'; +import { mapOriginalLineToModified } from '../zones/line-mapping'; +import { isActualSideBySide } from './useActualRenderSideBySide'; export interface PendingScroll { line: number; @@ -13,7 +15,7 @@ export interface PendingScroll { export interface PendingNav { runId?: string; findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; + anchor: { path: string; startLine: number; endLine: number; side?: 'old' | 'new' }; } /** @@ -31,6 +33,8 @@ export function useDiffNav(opts: { pendingNav: PendingNav | null | undefined; onNavConsumed: (() => void) | undefined; triggerAutoEdit: (draftId: string) => void; + /** Toolbar intent; the reveal reads the ACTUAL mode from this at reveal time (see isActualSideBySide) to place old-side targets. */ + renderSideBySide: boolean; }): { pendingScroll: PendingScroll | null; setPendingScroll: React.Dispatch>; @@ -45,6 +49,7 @@ export function useDiffNav(opts: { pendingNav, onNavConsumed, triggerAutoEdit, + renderSideBySide, } = opts; const [pendingScroll, setPendingScroll] = useState(null); @@ -71,7 +76,9 @@ export function useDiffNav(opts: { setPendingScroll({ // Take endLine to align with the draft zone / publish anchor (see zone line number comment), so the highlight line matches the draft zone line: pendingNav.anchor.endLine, - side: 'new', + // Preserve the anchor's side so an old-side (base) comment/draft reveals on the correct side; older producers that + // don't carry side fall back to 'new' (head), the common case for code-review findings. + side: pendingNav.anchor.side ?? 'new', draftId: matchingDraft?.id, }); onNavConsumed?.(); @@ -84,10 +91,6 @@ export function useDiffNav(opts: { // pendingScroll comes from the nav effect (set alongside setSelectedKey); cleared after reveal useEffect(() => { if (!pendingScroll || !diffEditor || !content || !selected) return; - const editor = - pendingScroll.side === 'old' - ? diffEditor.getOriginalEditor() - : diffEditor.getModifiedEditor(); let highlightTimer: ReturnType | undefined; let revealed = false; @@ -95,15 +98,27 @@ export function useDiffNav(opts: { // onDidUpdateDiff may fire multiple times, only jump once if (revealed) return; revealed = true; + // Resolve the target editor + line by side AND the ACTUAL render mode (read live here, after the diff has settled + // and Monaco's `.side-by-side` class is stable). An old-side target sits on the original editor only when it is + // genuinely visible; when Monaco has auto-degraded to unified the original editor is hidden, so remap the old line + // onto the modified editor (same mapping the old-side zones use) and reveal there — otherwise the reveal lands on + // an invisible editor / the wrong line. + const revealOld = + pendingScroll.side === 'old' && isActualSideBySide(diffEditor, renderSideBySide); + const editor = revealOld ? diffEditor.getOriginalEditor() : diffEditor.getModifiedEditor(); + const line = + pendingScroll.side === 'old' && !revealOld + ? mapOriginalLineToModified(diffEditor.getLineChanges() ?? [], pendingScroll.line) + : pendingScroll.line; // Center-scroll to the target line - editor.revealLineInCenter(pendingScroll.line); + editor.revealLineInCenter(line); // Brief highlight: 300ms yellow-background pulse const collection = editor.createDecorationsCollection([ { range: { - startLineNumber: pendingScroll.line, + startLineNumber: line, startColumn: 1, - endLineNumber: pendingScroll.line, + endLineNumber: line, endColumn: 1, }, options: { @@ -143,7 +158,7 @@ export function useDiffNav(opts: { }; // triggerAutoEdit not in deps — it changes reference every render, including it would make reveal rerun every frame, repeatedly locating/highlighting // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pendingScroll, diffEditor, content, selected]); + }, [pendingScroll, diffEditor, content, selected, renderSideBySide]); return { pendingScroll, setPendingScroll }; } diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts index 7c7f842f..b69086a9 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDiffOverviewMarks.ts @@ -2,6 +2,7 @@ import { useEffect } from 'react'; import { editor as MonacoEditorNs, type editor as MonacoEditor } from 'monaco-editor'; import type { DiffChangedFile } from '@meebox/ipc'; import type { LoadedContent } from '../diff-types'; +import { isActualSideBySide } from './useActualRenderSideBySide'; // Add/change green, delete red (same color family as GitHub diff). diff marks go on the overview ruler's Left lane, // separate from comment anchors (Right lane, see useCommentZones), so they don't overlap. @@ -38,14 +39,6 @@ export function useDiffOverviewMarks(opts: { const modCol = modifiedEditor.createDecorationsCollection([]); const origCol = originalEditor.createDecorationsCollection([]); - // Whether actually side-by-side: read Monaco's `.monaco-diff-editor.side-by-side` class that reflects the actual render mode - // (the class is removed when auto-downgrading to inline on insufficient width); fall back to the user intent prop when unavailable. - const isSideBySide = (): boolean => { - if (!renderSideBySide) return false; - const el = diffEditor.getContainerDomNode().querySelector('.monaco-diff-editor'); - return el ? el.classList.contains('side-by-side') : true; - }; - const Lane = MonacoEditorNs.OverviewRulerLane; const deco = ( startLine: number, @@ -59,7 +52,7 @@ export function useDiffOverviewMarks(opts: { const refresh = (): void => { const changes = diffEditor.getLineChanges() ?? []; - const sideBySide = isSideBySide(); + const sideBySide = isActualSideBySide(diffEditor, renderSideBySide); const modDecos: MonacoEditor.IModelDeltaDecoration[] = []; const origDecos: MonacoEditor.IModelDeltaDecoration[] = []; for (const c of changes) { diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx index f6a5d175..87174018 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/hooks/useDraftZones.tsx @@ -1,14 +1,22 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { type editor as MonacoEditor } from 'monaco-editor'; import type { PlatformKind, PlatformUser, ReviewDraft } from '@meebox/shared'; import type { DiffChangedFile } from '@meebox/ipc'; import { DraftZoneList } from '../DraftZoneList'; -import { mountInlineZones } from '../zones/mountInlineZones'; +import { createInlineZones, type InlineZonesController } from '../zones/mountInlineZones'; import type { LoadedContent } from '../diff-types'; /** - * Inline draft view zones (blue background, editable). Uses the same mountInlineZones mechanism as comment zones, bucketed by anchor.side, - * with endLine as the zone line number (aligned with the publish anchor, WYSIWYG). + * Inline draft view zones (blue background, editable). Uses the same zone mechanism as comment zones, bucketed by + * anchor.side, with endLine as the zone line number (aligned with the publish anchor, WYSIWYG). + * + * Split into two effects like {@link useCommentZones}: a **structural** effect owns the zone controller's lifecycle + * (recreated only when the editor / file / view orientation / scope changes); a **content** effect calls + * `controller.update(...)` whenever the drafts or passthrough props change, which **reconciles** zones by (side, line) + * key rather than tearing them down. This lets an in-progress inline draft edit survive a drafts/comments refresh + * (e.g. the poller pulling a new remote comment mid-typing, or another draft being added elsewhere): the anchored + * line's zone keeps its React root, so the open editor's text / focus / caret aren't discarded. A genuinely removed + * draft (deleted / published / anchor moved / file switch) still unmounts, firing its useDraftZone cancel cleanup. * * Does not render rejected (user decided not to send) / posted (the remote comment is already taken over by CommentZone; re-rendering would be visually duplicate). * The commit read-only view (scopeKind !== 'all') does not render drafts (anchored on the PR full-diff line numbers, not applicable to a single commit). @@ -48,10 +56,44 @@ export function useDraftZones(opts: { scopeKind, } = opts; + // Structural lifecycle: (re)create the zone controller only when the editor / file / view orientation / scope + // changes. A drafts or comments refresh does NOT touch these deps, so the controller (and its live zones) survives — + // the content effect below then reconciles into it instead of tearing everything down. + const controllerRef = useRef | null>(null); useEffect(() => { if (!diffEditor || !content || !selected) return; // commit read-only view: does not render local draft zones (drafts are anchored on the PR full-diff line numbers, not applicable to a single commit). if (scopeKind !== 'all') return; + const controller = createInlineZones({ + diffEditor, + renderSideBySide, + zoneClassName: 'monaco-draft-zone', + innerClassName: 'monaco-draft-zone-inner', + stopEvents: [ + 'mousedown', + 'mouseup', + 'click', + 'dblclick', + 'keydown', + 'keyup', + 'wheel', + 'contextmenu', + ], + }); + controllerRef.current = controller; + return () => { + controller.dispose(); + controllerRef.current = null; + }; + }, [diffEditor, content, selected, renderSideBySide, scopeKind]); + + // Content sync: reconcile draft zones whenever the drafts / passthrough props change. Declared after the structural + // effect so on mount the controller exists before this runs (React runs setup in order). Reconcile means a surviving + // draft re-renders in place (an open editor keeps its text / focus), only added/removed drafts mount/unmount. + useEffect(() => { + const controller = controllerRef.current; + if (!controller || !diffEditor || !content || !selected) return; + if (scopeKind !== 'all') return; const fileDrafts = (drafts ?? []).filter((d) => { if (d.status === 'rejected' || d.status === 'posted') return false; // Reply-drafts render nested under their parent comment (ReplyDraftList), not as standalone line zones — skip them here. @@ -59,7 +101,6 @@ export function useDraftZones(opts: { if (!d.anchor) return false; return d.anchor.path === selected.path || selected.oldPath === d.anchor.path; }); - if (fileDrafts.length === 0) return; const oldByLine = new Map(); const newByLine = new Map(); @@ -75,23 +116,12 @@ export function useDraftZones(opts: { target.set(anchor.endLine, arr); } - return mountInlineZones({ - diffEditor, - renderSideBySide, + // Reconcile: unchanged draft lines re-render in place (an open draft editor keeps its text / focus / caret), only + // genuinely added/removed lines mount/unmount. An empty set removes all remaining zones (no early-return, so + // deleting the last draft on a line tears its zone down through the controller). + controller.update({ oldByLine, newByLine, - zoneClassName: 'monaco-draft-zone', - innerClassName: 'monaco-draft-zone-inner', - stopEvents: [ - 'mousedown', - 'mouseup', - 'click', - 'dblclick', - 'keydown', - 'keyup', - 'wheel', - 'contextmenu', - ], initialHeight: (ds) => Math.max(ds.length * 60, 80), render: (ds) => ( ), }); - // Does not depend on zone rebuilds triggered by autoEditTokens / registerEditTrigger (registerEditTrigger is a stable - // useCallback) — avoids trigger-induced DraftZone unmount/mount, eliminating the race of re-entering edit mode after cancel. }, [ diffEditor, drafts, diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts index 717ee8c9..088baf62 100644 --- a/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/diff/zones/mountInlineZones.ts @@ -10,35 +10,14 @@ import { remapOldByLineToModified } from './line-mapping'; * `removeZone` / `unmount` cleanup. The differences (which events to intercept, initial height estimation, what component * to render) are injected via options. * - * Two entry points sharing the same internals: - * - {@link createInlineZones} returns a persistent controller whose `update(content)` **reconciles** zones by - * `(side, line)` key: an unchanged key re-renders its existing React root in place (preserving the zone's React - * state, e.g. an in-progress inline reply/edit that must survive a comments refresh / poll), a new key mounts a - * fresh zone, a vanished key is removed. Use this when the zone content changes independently of the editor/file. - * - {@link mountInlineZones} is the original one-shot form (create + populate once, teardown on cleanup), kept for - * callers that rebuild their whole zone set per effect run (the draft zones). + * {@link createInlineZones} returns a persistent controller whose `update(content)` **reconciles** zones by + * `(side, line)` key: an unchanged key re-renders its existing React root in place (preserving the zone's React + * state, e.g. an in-progress inline reply/edit/draft that must survive a comments/drafts refresh / poll), a new key + * mounts a fresh zone, a vanished key is removed. Both the inline comment zones (useCommentZones) and the inline draft + * zones (useDraftZones) drive it from a split structural/content effect pair, so their editors survive a refresh. * * The comment zone's extra glyph decorations are not managed here (the caller useCommentZones creates / clears them itself). */ -export interface MountInlineZonesOptions { - diffEditor: MonacoEditor.IStandaloneDiffEditor; - renderSideBySide: boolean; - /** Old-side (deleted / base-side context lines) buckets: key = line number */ - oldByLine: Map; - /** New-side (added / head-side context lines) buckets: key = line number */ - newByLine: Map; - /** Class of the monaco wrapper dom ('monaco-comment-zone' / 'monaco-draft-zone') */ - zoneClassName: string; - /** Class of the real visual container inner ('monaco-comment-zone-inner' / 'monaco-draft-zone-inner') */ - innerClassName: string; - /** Set of events stopPropagation takes over on dom + inner (drafts include keydown/wheel etc., comments only mouse-click kinds) */ - stopEvents: readonly string[]; - /** Initial zone height (px) estimation; lineHeight is monaco's current line height */ - initialHeight: (items: T[], lineHeight: number) => number; - /** Render the zone content (React node) */ - render: (items: T[]) => ReactNode; -} - /** Structural options for {@link createInlineZones}: everything that identifies where/how zones mount, minus the content. */ export interface CreateInlineZonesOptions { diffEditor: MonacoEditor.IStandaloneDiffEditor; @@ -269,6 +248,9 @@ export function createInlineZones(opts: CreateInlineZonesOptions): InlineZone for (const [line, items] of content.newByLine) { desired.set(`new:${line}`, { editor: modifiedEditor, afterLine: line, items }); } + // renderSideBySide here is the ACTUAL render mode (callers pass useActualRenderSideBySide, not the toolbar intent): + // only mount old-side zones on the original editor when it is genuinely visible; when Monaco has auto-degraded to + // inline the original editor is hidden, so remap old lines onto the modified editor instead. if (renderSideBySide) { for (const [line, items] of content.oldByLine) { desired.set(`old:${line}`, { editor: originalEditor, afterLine: line, items }); @@ -376,25 +358,3 @@ export function createInlineZones(opts: CreateInlineZonesOptions): InlineZone return { update, dispose }; } - -/** - * One-shot form (create + populate once, teardown on cleanup). Behaviourally identical to the pre-controller - * mechanism, kept for callers (the draft zones) that rebuild their entire zone set on each effect run. Returns a - * cleanup function to call in the effect's teardown. - */ -export function mountInlineZones(opts: MountInlineZonesOptions): () => void { - const controller = createInlineZones({ - diffEditor: opts.diffEditor, - renderSideBySide: opts.renderSideBySide, - zoneClassName: opts.zoneClassName, - innerClassName: opts.innerClassName, - stopEvents: opts.stopEvents, - }); - controller.update({ - oldByLine: opts.oldByLine, - newByLine: opts.newByLine, - initialHeight: opts.initialHeight, - render: opts.render, - }); - return () => controller.dispose(); -} diff --git a/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/commentEquality.ts b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/commentEquality.ts new file mode 100644 index 00000000..afec5377 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/features/pr/tabs/shared/commentEquality.ts @@ -0,0 +1,39 @@ +import type { PrComment } from '@meebox/shared'; + +/** + * Structural equality comparison of the comment tree (by remoteId + body + version + edit/delete permissions + recursive replies). poll mostly returns + * comments with unchanged content: on equality, callers skip setState and keep the old reference so React bails out, avoiding pointless re-render of the + * whole comment tree (including inline Monaco) and — in the diff view — the teardown/rebuild of open inline editors driven by the comments-array churn. + */ +export function sameCommentList(a: readonly PrComment[], b: readonly PrComment[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i]!; + const y = b[i]!; + if ( + x.remoteId !== y.remoteId || + x.body !== y.body || + x.version !== y.version || + x.canEdit !== y.canEdit || + x.canDelete !== y.canDelete || + !sameReactions(x.reactions, y.reactions) || + !sameCommentList(x.replies, y.replies) + ) { + return false; + } + } + return true; +} + +/** Equality comparison of the reactions array (emoji + count + mine triple matching item by item): lets reaction changes after a toggle trigger a re-render. */ +function sameReactions(a: PrComment['reactions'], b: PrComment['reactions']): boolean { + const x = a ?? []; + const y = b ?? []; + if (x.length !== y.length) return false; + for (let i = 0; i < x.length; i++) { + if (x[i]!.emoji !== y[i]!.emoji || x[i]!.count !== y[i]!.count || x[i]!.mine !== y[i]!.mine) { + return false; + } + } + return true; +} 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/hooks/usePrNavigation.ts b/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts index 4c5a457c..2b65d6d7 100644 --- a/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts +++ b/apps/desktop/src/renderer/src/hooks/usePrNavigation.ts @@ -10,7 +10,9 @@ import { formatBackendError } from '../errors'; export interface PendingDiffNav { runId?: string; findingId?: string; - anchor: { path: string; startLine: number; endLine: number }; + // `side` decides which diff side the reveal targets (old = base/left, new = head/right); omitted defaults to 'new'. + // Required so an old-side comment/draft reveal lands correctly (incl. after an auto-degrade to unified — see useDiffNav). + anchor: { path: string; startLine: number; endLine: number; side?: 'old' | 'new' }; } export interface PrNavigation { @@ -156,7 +158,9 @@ export function usePrNavigation({ return subscribe('notification:activate', ({ localId, kind, anchor }) => { void jumpToPr(localId); if (anchor) { - setPendingDiffNav({ anchor: { path: anchor.path, startLine: anchor.line, endLine: anchor.line } }); + setPendingDiffNav({ + anchor: { path: anchor.path, startLine: anchor.line, endLine: anchor.line, side: anchor.side }, + }); } else if (kind === 'mention' || kind === 'reply') { setPendingTab('activity'); } 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 c078be57..f4083b55 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json +++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json @@ -200,6 +200,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", @@ -568,6 +569,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 b1174899..def9c738 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json @@ -200,6 +200,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", @@ -568,6 +569,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 3f6a3749..5f1542f7 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json +++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json @@ -200,6 +200,7 @@ "cmdOpenDevtools": "DevTools を開く", "cmdOpenPrUrl": "URL で開く(現在のプラットフォーム)", "cmdOpenSettings": "設定を開く", + "cmdRefreshPr": "PR を更新", "cmdRunAutoReview": "自動レビューを実行", "cmdSwitchLanguage": "表示言語を切り替え", "cmdSwitchModel": "モデルを切り替え", @@ -554,6 +555,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 9d3b61b5..e220dc1b 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json @@ -200,6 +200,7 @@ "cmdOpenDevtools": "打开 DevTools", "cmdOpenPrUrl": "打开 URL(当前平台)", "cmdOpenSettings": "打开设置", + "cmdRefreshPr": "刷新 PR", "cmdRunAutoReview": "运行自动评审", "cmdSwitchLanguage": "切换显示语言", "cmdSwitchModel": "切换模型", @@ -554,6 +555,7 @@ "ownPrReason": "不能审批自己的 PR", "publishComments": "提交评论 ({{n}})", "publishCommentsTitle": "批量发布 {{n}} 条草稿到远端", + "refreshTitle": "刷新", "showBlame": "开启追溯显示(仅 head 侧)", "showWhitespace": "显示空白字符(空格 / Tab)", "sideBySide": "并排", diff --git a/apps/desktop/src/renderer/src/styles/features/chat/pane.scss b/apps/desktop/src/renderer/src/styles/features/chat/pane.scss index 10b5f670..91dcde0b 100644 --- a/apps/desktop/src/renderer/src/styles/features/chat/pane.scss +++ b/apps/desktop/src/renderer/src/styles/features/chat/pane.scss @@ -102,6 +102,39 @@ border-bottom: 1px dashed $border-muted; } +// "Commit divider": a sawtooth rule inserted into the run timeline where the PR code changed between two runs (see +// ChatPane commitDividers). A zigzag line flanks a centered commit-id chip, marking the newer commit the runs below it +// ran against, so history runs on an older revision read as a separate section. The zigzag is a masked SVG tile, so its +// color follows the theme token ($border-muted) via background-color and it renders correctly in both light and dark. +$commit-zigzag: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='12'%20height='8'%20viewBox='0%200%2012%208'%3E%3Cpath%20d='M0%206%20L3%202%20L6%206%20L9%202%20L12%206'%20fill='none'%20stroke='black'%20stroke-width='1.4'%20stroke-linejoin='round'/%3E%3C/svg%3E"); +.chat-commit-divider { + display: flex; + align-items: center; + gap: $space-3; + margin: $space-6 $space-2 $space-3; + user-select: none; + + &__line { + flex: 1 1 auto; + height: 8px; + background-color: $border-muted; + opacity: 0.75; + -webkit-mask: $commit-zigzag repeat-x center / auto 100%; + mask: $commit-zigzag repeat-x center / auto 100%; + } + + // Keep the commit-id chip at its intrinsic width; only the flanking zigzag lines flex. Add a small gap between the + // commit icon and the sha (the chip mixin sets none), and make the svg a block so it aligns to the sha's line box. + &__chip { + flex: 0 0 auto; + gap: $space-2; + + svg { + display: block; + } + } +} + // Rule chip matched by the current PR: a row below the actions bar, clicking pops up a body preview .chat-rule-chip { display: flex; 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/assets/README.md b/assets/README.md index 2f379131..3ccdc0d1 100644 --- a/assets/README.md +++ b/assets/README.md @@ -1,29 +1,31 @@ -# assets — 产品资源 +# assets — product resources -存放品牌 / UI 源资源(图标、图片等)。二进制图片经 **Git LFS** 跟踪(规则见根 -`.gitattributes`),避免仓库膨胀;SVG 等文本资源走普通 git。 +Source brand / UI resources (icons, images, etc.). Binary images are tracked via **Git LFS** (rules in the root +`.gitattributes`) to keep the repo from bloating; text resources such as SVG go through plain git. -## 结构 +## Structure -- `icons/` — 产品图标源(建议 1024×1024 PNG)及导出的 `.ico` / `.icns` -- `images/` — 其它图片素材(截图、插图等) +- `icons/` — product icon sources (1024×1024 PNG recommended) plus the exported `.ico` / `.icns` +- `images/` — other image assets (screenshots, illustrations, etc.) -## 图标接入 +## Icon wiring -产品图标源 `icons/icon.png`(1024×1024),导出的 Windows `icons/icon.ico`(含 16/32/48/256) -由 electron-builder 显式引用(`win.icon: ../../assets/icons/icon.ico`);macOS / Linux -直接用 `icon.png`(mac 构建时转 `.icns`)。 +The product icon source `icons/icon.png` (1024×1024) and the exported Windows `icons/icon.ico` (containing 16/32/48/256) +are referenced explicitly by electron-builder (`win.icon: ../../assets/icons/icon.ico`); macOS / Linux use `icon.png` +directly (converted to `.icns` at mac build time). -> 不放 `apps/desktop/build/`:该目录被 `.gitignore` 忽略,故图标统一放本资源目录、显式引用。 +> Not placed under `apps/desktop/build/`: that directory is ignored by `.gitignore`, so icons live in this resource +> directory and are referenced explicitly. -重新生成 `.ico`(PNG 改动后): +Regenerate the `.ico` (after changing the PNG): ```bash npx --yes png-to-ico assets/icons/icon.png > assets/icons/icon.ico ``` -## LFS 提示 +## LFS notes -- 首次 clone 后若图片显示为指针文本,执行 `git lfs pull`。 -- 新增图片直接 `git add`;命中 `.gitattributes` 扩展名即自动入 LFS,无需手动 `git lfs track`。 -- 校验某文件是否走 LFS:`git check-attr filter -- assets/icons/foo.png`。 +- If images show up as pointer text after a fresh clone, run `git lfs pull`. +- Add a new image with a plain `git add`; matching a `.gitattributes` extension puts it into LFS automatically, with no + manual `git lfs track`. +- Check whether a file goes through LFS: `git check-attr filter -- assets/icons/foo.png`. diff --git a/package-lock.json b/package-lock.json index 7eed3d82..647509c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,7 @@ }, "apps/desktop": { "name": "@meebox/desktop", - "version": "0.11.1", + "version": "0.11.2", "dependencies": { "@iconify-json/material-icon-theme": "^1.2.66", "@iconify/react": "^5.2.1", diff --git a/packages/agent/src/planner.ts b/packages/agent/src/planner.ts index 49dd7ff8..5514d5e0 100644 --- a/packages/agent/src/planner.ts +++ b/packages/agent/src/planner.ts @@ -8,7 +8,11 @@ import type { } from '@meebox/shared'; import { assembleSystemContext, type AssemblePrMeta } from './prompts.js'; import type { MemoryNote } from './memory.js'; -import { DEFAULT_STEP_LABELS, DEFAULT_SUMMARY_SECTIONS, type AgentStepLabels } from './orchestrator.js'; +import { + DEFAULT_STEP_LABELS, + DEFAULT_SUMMARY_SECTIONS, + type AgentStepLabels, +} from './orchestrator.js'; import { createStepRecorder } from './steps/context.js'; import { buildConversationContext, @@ -69,6 +73,12 @@ export interface PlanningInput { * agent remembers earlier exchanges across rounds; **never** passed through to pr-agent tools (tools only see PR + this round's question). */ history?: AgentMessage[]; + /** + * Current PR head commit SHA. When it differs from the newest historical message's headSha (or between historical + * turns), buildConversationContext injects a "code changed to commit X" marker so the agent knows earlier discussion + * may reference outdated code. Context awareness only — never passed through to pr-agent tools. + */ + currentHeadSha?: string; /** * Code reference selected by the user in the diff (self-describing block). Injected into this round's planning context so the agent knows which code the user is looking at; * **never** passed through to pr-agent tools (same constraint as history). Omitted = no selection reference this round. @@ -130,7 +140,7 @@ export async function runPlanningAgent( // Inject prior multi-turn conversation into the planning context (trimmed by budget) so the agent remembers exchanges across rounds; only for the planning LLM's reference, // never passed through to pr-agent tools. - const convo = buildConversationContext(input.history ?? []); + const convo = buildConversationContext(input.history ?? [], input.currentHeadSha); const ctx: PlanStepCtx = { deps, input, @@ -149,7 +159,13 @@ export async function runPlanningAgent( for (let i = 0; i < maxSteps; i++) { const outcome = await planCycleStep.run(ctx); if (outcome.kind === 'aborted') { - return { steps: rec.steps, finalText: '', tokenUsage: rec.usage, memories, terminationReason: 'aborted' }; + return { + steps: rec.steps, + finalText: '', + tokenUsage: rec.usage, + memories, + terminationReason: 'aborted', + }; } if (outcome.kind === 'final') { return { @@ -162,5 +178,11 @@ export async function runPlanningAgent( } } - return { steps: rec.steps, finalText: '', tokenUsage: rec.usage, memories, terminationReason: 'max_steps' }; + return { + steps: rec.steps, + finalText: '', + tokenUsage: rec.usage, + memories, + terminationReason: 'max_steps', + }; } diff --git a/packages/agent/src/steps/planning/shared.ts b/packages/agent/src/steps/planning/shared.ts index b3f16858..7b61587c 100644 --- a/packages/agent/src/steps/planning/shared.ts +++ b/packages/agent/src/steps/planning/shared.ts @@ -105,18 +105,49 @@ export function normalizePlan(raw: PlannerAction['plan']): AgentTodoItem[] { return out; } -/** Take the most recent rounds, each length-capped, and trim newest-to-oldest by total budget (drop earlier over-budget messages), returning text in ascending time order. */ -export function buildConversationContext(history: readonly AgentMessage[]): string { - const lines: string[] = []; +/** Boundary marker injected into the conversation context where the PR head commit changed, so the planning agent knows + * messages above it were made against older code (soft awareness — no history is dropped). Internal prompt artifact → English. */ +function commitChangeMarker(sha: string): string { + return `[--- PR code updated to commit ${sha.slice(0, 8)} at this point; messages below are based on the new code, earlier ones may reference outdated code ---]`; +} + +/** + * Take the most recent rounds, each length-capped, and trim newest-to-oldest by total budget (drop earlier over-budget + * messages), returning text in ascending time order. Where a message's `headSha` differs from the previous kept + * message's, a {@link commitChangeMarker} is interleaved so the agent perceives the code changed between those turns; + * a trailing marker is added when `currentHeadSha` (this turn's head) has advanced past the newest kept message. + * Messages without a recorded headSha (predating the field) never produce a marker. + */ +export function buildConversationContext( + history: readonly AgentMessage[], + currentHeadSha?: string, +): string { + // First pass, newest→oldest: keep the messages that fit the budget. + const kept: AgentMessage[] = []; let budget = HISTORY_BUDGET_CHARS; for (let i = history.length - 1; i >= 0; i--) { const m = history[i]!; const line = `${m.role === 'user' ? 'User' : 'Assistant'}: ${clamp(m.content, HISTORY_MESSAGE_MAX)}`; if (line.length + 1 > budget) break; // budget exhausted: trim the earlier conversation entirely budget -= line.length + 1; - lines.push(line); + kept.push(m); + } + kept.reverse(); // ascending time order + // Second pass, ascending: render each message, inserting a commit-change marker at head-commit boundaries. + const lines: string[] = []; + let prevSha: string | undefined; + for (const m of kept) { + if (m.headSha && prevSha && m.headSha !== prevSha) lines.push(commitChangeMarker(m.headSha)); + if (m.headSha) prevSha = m.headSha; + lines.push( + `${m.role === 'user' ? 'User' : 'Assistant'}: ${clamp(m.content, HISTORY_MESSAGE_MAX)}`, + ); + } + // Trailing marker: the current turn's code has advanced past the newest historical message. + if (currentHeadSha && prevSha && currentHeadSha !== prevSha) { + lines.push(commitChangeMarker(currentHeadSha)); } - return lines.reverse().join('\n'); + return lines.join('\n'); } /** Planning ReAct protocol: body is externalized in resources/prompts/protocol.md, the three section titles (localized per language) are injected via placeholders. */ diff --git a/packages/ipc/src/events.ts b/packages/ipc/src/events.ts index a0ff45fa..b7ad9ada 100644 --- a/packages/ipc/src/events.ts +++ b/packages/ipc/src/events.ts @@ -84,7 +84,7 @@ export interface IpcEvents { 'notification:activate': { localId: string; kind: PollNotificationKind; - anchor: { path: string; line: number } | null; + anchor: { path: string; line: number; side: 'old' | 'new' } | null; }; } 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': { diff --git a/packages/poller/src/runs.ts b/packages/poller/src/runs.ts index 6faf6311..910cff0a 100644 --- a/packages/poller/src/runs.ts +++ b/packages/poller/src/runs.ts @@ -61,6 +61,8 @@ export interface StartReviewRunInput { origin?: ReviewRun['origin']; /** Single-commit review scope (parent..sha); omitted = full PR scope. Persisted for the result card's scope badge. */ scope?: ReviewRun['scope']; + /** PR head commit SHA the run runs against (pr.sourceRef.sha); persisted so ChatPane can draw a commit divider between runs across a code change. */ + headSha?: string; } /** Write the initial running state; callers must start before invoking pr-agent. */ @@ -81,6 +83,7 @@ export async function startReviewRun( referencedFinding: input.referencedFinding, origin: input.origin, scope: input.scope, + headSha: input.headSha, status: 'running', startedAt: at.toISOString(), }; diff --git a/packages/shared/src/agent-contract.ts b/packages/shared/src/agent-contract.ts index 1a6465f8..e6cc6bf4 100644 --- a/packages/shared/src/agent-contract.ts +++ b/packages/shared/src/agent-contract.ts @@ -82,6 +82,13 @@ export interface AgentMessage { * not set when there is no selection / for assistant messages. (Finding references go through the /ask run card, not this field.) */ referencedContext?: string; + /** + * PR head commit SHA (`pr.sourceRef.sha`) at the time this turn was recorded. Used by the planning agent's context + * assembly (buildConversationContext) to inject a "code changed to commit X" marker between turns whose head differs, + * so the agent perceives that earlier discussion may reference outdated code — without dropping any history. Messages + * predating this field are undefined and simply produce no marker. + */ + headSha?: string; /** Creation time (ISO), used for timeline ordering. */ at: string; } diff --git a/packages/shared/src/poller-contract.ts b/packages/shared/src/poller-contract.ts index 66ea2824..5b6ecd5b 100644 --- a/packages/shared/src/poller-contract.ts +++ b/packages/shared/src/poller-contract.ts @@ -314,6 +314,13 @@ export interface ReviewRun { * Default = whole-PR scope. The result card shows a scope badge accordingly. */ scope?: ReviewRunCommitScope; + /** + * PR head commit SHA (`pr.sourceRef.sha`) the run was executed against, stamped at start. Used only for the ChatPane + * "commit divider": when two consecutive runs have differing headSha, the code changed between them, and a sawtooth + * divider marking the newer commit is inserted between the older (history) runs and the newer ones. Historical runs + * predating this field are undefined → no divider is drawn for them (compared only between two defined, differing SHAs). + */ + headSha?: string; /** The pr-agent version obtained at probe time (CLI first line / the pr-agent version found by the embedded runtime) */ prAgentVersion: string; strategy: PrAgentStrategy;