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 b3f976a1..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, useMemo, 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'; @@ -242,23 +244,47 @@ export function ChatPane({ prLocalId, }); - // Commit divider: the PR head SHA the most recent completed run reviewed. When the current PR head has advanced past - // it, a single sawtooth divider is shown at the bottom of the timeline marking the new head — signalling prior - // reviews are now stale, even if no run has been started against the new code yet. Suppressed while a run is active - // (it's already processing the current head). Returns the new head SHA to mark, or null when nothing is stale. - const staleHeadSha = useMemo(() => { - if (hasMyActive) return null; - const head = pr?.sourceRef.sha; - if (!head) return null; - // Timeline is ascending, so the last run entry carrying a headSha is the most recent completed review. - let lastRunHeadSha: string | undefined; + // 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) { - if (entry.run?.headSha) lastRunHeadSha = entry.run.headSha; + const sha = entry.run?.headSha; + if (!sha) continue; + if (prevSha && sha !== prevSha) before.set(entry.key, sha); + prevSha = sha; } - // No baseline run with a recorded head (e.g. only pre-feature runs) → nothing to be stale against. - if (!lastRunHeadSha) return null; - return head !== lastRunHeadSha ? head : null; - }, [timeline, hasMyActive, pr?.sourceRef.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); @@ -375,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. @@ -416,9 +451,15 @@ export function ChatPane({ ) : null, )} - {/* Commit divider: the PR head advanced past the last reviewed commit → mark the new head at the bottom of the - run list (prior reviews are stale). Shown even when no run has been started against the new code yet. */} - {staleHeadSha && } + {/* 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 index e1385cbb..93917e83 100644 --- a/apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx +++ b/apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx @@ -1,17 +1,16 @@ -import { useTranslation } from 'react-i18next'; 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 label is the abbreviated - * commit id (the full SHA is in the tooltip), reusing the same chip vocabulary as the single-commit scope badge in - * RunResultView. + * 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 }: { sha: string }) { - const { t } = useTranslation(); +export function CommitDivider({ sha, message }: { sha: string; message?: string }) { const short = sha.slice(0, 8); - const title = t('chatPane.commitDividerTitle', { sha }); + // Tooltip = the actual commit message (more meaningful than a generic "code changed" string, and needs no i18n). + const title = message?.trim() || short; return (