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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and the versioning follows [Semantic Versioning](https://semver.org/).

### 🔧 Fixed

- The commit divider in the review timeline now stays where the commit boundary actually is, instead of sliding further down every time a new message appears below it.
- The file list in a generated PR description now shows the real number of added and removed lines per file, instead of `+-1/--1`.
- Links in that file list now open the file instead of pointing at a non-existent line, so clicking through works.
- A PR description ending in a git merge tail (`# Conflicts:` and the file lines under it) no longer breaks up the generated description — those lines are part of the quoted description, not headings of their own.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

### 🔧 修复

- 评审时间线中的提交分割线现在固定在提交边界的真实位置,不再因为下方出现新消息而一路下移。
- 生成的 PR 描述中,文件清单现在显示每个文件真实的增删行数,不再是 `+-1/--1`。
- 该清单中的链接现在指向文件本身,而非一个不存在的行号,点击可正常跳转。
- PR 描述末尾带有 git 合并残留(`# Conflicts:` 及其下的文件行)时,生成的描述不再被切碎——那些行属于被引用的描述正文,而不是标题。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useChatTimeline } from './hooks/useChatTimeline';
import { AgentStepRow, ThinkingLive } from './components/AgentStep';
import { ChatEmpty } from './components/ChatEmpty';
import { CommitDivider } from './components/CommitDivider';
import { computeCommitDividers } from './utils/commit-dividers';
import { ChatInputBar } from './components/ChatInputBar';
import { ConversationMessage } from './components/ConversationMessage';
import { PlanPanel } from './components/PlanPanel';
Expand Down Expand Up @@ -244,26 +245,12 @@ 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<string, string>(); // 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 dividers: every point in the timeline where the reviewed commit changes (see computeCommitDividers, which
// also explains why the trailing boundary anchors after the last run rather than at the end of the pane).
const commitDividers = useMemo(
() => computeCommitDividers(timeline, pr?.sourceRef.sha),
[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).
Expand Down Expand Up @@ -451,9 +438,10 @@ export function ChatPane({
<ConversationMessage key={entry.key} message={entry.message} />
) : 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). */}
{/* Trailing commit divider, for the one case with no entry to precede: the head advanced past the last run's
commit and nothing has landed after that run yet. As soon as anything does — a message, a step, a queued
run — the boundary moves into `before` and anchors there, so it stays put instead of being pushed down by
each new bubble (see computeCommitDividers). */}
{commitDividers.bottom && (
<CommitDivider
sha={commitDividers.bottom}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { TimelineEntry } from '../hooks/useChatTimeline';

export interface CommitDividers {
/** timeline entry.key → the headSha whose boundary is drawn immediately *before* that entry. */
before: Map<string, string>;
/** headSha for a divider after everything, used only when the boundary has no entry to precede. */
bottom: string | null;
}

/**
* Place the commit boundaries in the chat timeline: every point where the reviewed commit changes, so the boundary
* persists rather than vanishing once the new code has been reviewed.
*
* - **Between runs**: two consecutive runs with different `headSha` → a boundary before the newer one, separating the
* old-commit runs above from the new-commit ones below.
* - **After the newest run**: the PR head has advanced past the last run's commit and nothing has reviewed it yet.
* The boundary belongs immediately after that last run — **not at the end of the timeline**. Those differ as soon as
* anything else lands (a message, a thinking step, a queued run), and anchoring to the end made the divider drift
* down the pane on every new bubble, as if the boundary itself kept moving. A boundary marks a point in history; it
* has to stay where that point is, with everything that came after it below.
*
* The timeline is ascending by start time; only runs that recorded a `headSha` participate (runs from before that field
* existed are skipped, which is why the last *run* and the last *sha-bearing run* are tracked as the same cursor).
*/
export function computeCommitDividers(
timeline: readonly TimelineEntry[],
headSha: string | undefined,
): CommitDividers {
const before = new Map<string, string>();
let prevSha: string | undefined;
let lastShaIdx = -1;
timeline.forEach((entry, i) => {
const sha = entry.run?.headSha;
if (!sha) return;
if (prevSha && sha !== prevSha) before.set(entry.key, sha);
prevSha = sha;
lastShaIdx = i;
});
// No run has recorded a sha, or the head is the one already reviewed → no trailing boundary.
if (!headSha || !prevSha || headSha === prevSha) return { before, bottom: null };
// Anchor the boundary before whatever first followed that last run. Nothing follows it yet → fall back to the end,
// which is then the same position (and stops being a moving target as soon as an entry appears there).
const next = timeline[lastShaIdx + 1];
if (next) {
before.set(next.key, headSha);
return { before, bottom: null };
}
return { before, bottom: headSha };
}
Loading