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
117 changes: 79 additions & 38 deletions apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>(); // 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<Map<string, string>>(() => 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);
Expand Down Expand Up @@ -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).
<div key={entry.key} data-run-id={entry.run.id}>
<RunResultView
run={entry.run}
onRetry={actions.handleRetry}
onDelete={actions.handleDeleteRun}
// Only in the single case of "the last run in the timeline + nothing running" can a failed / cancelled run
// be retried; once the user has started a new action (whether succeeded or running) → old failures no longer show retry,
// avoiding a back-click re-queue that would disrupt conversation order
canRetry={i === timeline.length - 1 && !hasMyActive}
drafts={drafts ?? []}
closures={closures}
onJumpToDraft={actions.handleJumpToDraft}
onRejectFinding={actions.handleRejectFinding}
onNavigateToFinding={actions.handleNavigateToFinding}
onReferenceFinding={onReferenceFinding}
onScrollToRun={scrollToRun}
onScrollToFinding={scrollToFinding}
/>
</div>
<Fragment key={entry.key}>
{commitDividers.before.has(entry.key) && (
<CommitDivider
sha={commitDividers.before.get(entry.key)!}
message={commitMsgBySha.get(commitDividers.before.get(entry.key)!)}
/>
)}
<div data-run-id={entry.run.id}>
<RunResultView
run={entry.run}
onRetry={actions.handleRetry}
onDelete={actions.handleDeleteRun}
// Only in the single case of "the last run in the timeline + nothing running" can a failed / cancelled run
// be retried; once the user has started a new action (whether succeeded or running) → old failures no longer show retry,
// avoiding a back-click re-queue that would disrupt conversation order
canRetry={i === timeline.length - 1 && !hasMyActive}
drafts={drafts ?? []}
closures={closures}
onJumpToDraft={actions.handleJumpToDraft}
onRejectFinding={actions.handleRejectFinding}
onNavigateToFinding={actions.handleNavigateToFinding}
onReferenceFinding={onReferenceFinding}
onScrollToRun={scrollToRun}
onScrollToFinding={scrollToFinding}
/>
</div>
</Fragment>
) : 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.
Expand All @@ -416,9 +451,15 @@ export function ChatPane({
<ConversationMessage key={entry.key} message={entry.message} />
) : 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 && <CommitDivider sha={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 && (
<CommitDivider
sha={commitDividers.bottom}
message={commitMsgBySha.get(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) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="chat-commit-divider" role="separator" aria-label={title}>
<span className="chat-commit-divider__line" aria-hidden="true" />
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
"codeExistingAria": "Originalcode",
"codeImprovedAria": "Verbesserter Code",
"commandNoArgs": "{{cmd}} akzeptiert keine Argumente",
"commitDividerTitle": "Code seit der letzten Review geändert — jetzt bei Commit {{sha}}",
"deleteRunAria": "Löschen",
"deleteRunTitle": "Diesen Lauf löschen",
"draftJumpEditTitle": "Im Code bearbeiten",
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
"codeExistingAria": "Original code",
"codeImprovedAria": "Improved code",
"commandNoArgs": "{{cmd}} does not accept arguments",
"commitDividerTitle": "Code changed since the last review — now at commit {{sha}}",
"deleteRunAria": "Delete",
"deleteRunTitle": "Delete this run",
"draftJumpEditTitle": "Edit in code",
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
"codeExistingAria": "元のコード",
"codeImprovedAria": "改善後のコード",
"commandNoArgs": "{{cmd}} は引数を受け付けません",
"commitDividerTitle": "前回のレビュー以降にコードが変更されました — 現在はコミット {{sha}}",
"deleteRunAria": "削除",
"deleteRunTitle": "この記録を削除",
"draftJumpEditTitle": "コード内で編集",
Expand Down
1 change: 0 additions & 1 deletion apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@
"codeExistingAria": "原代码",
"codeImprovedAria": "改进代码",
"commandNoArgs": "{{cmd}} 不接受参数",
"commitDividerTitle": "自上次评审后代码已变更 —— 当前为提交 {{sha}}",
"deleteRunAria": "删除",
"deleteRunTitle": "删除此记录",
"draftJumpEditTitle": "在代码中编辑",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,15 @@ $commit-zigzag: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/s
mask: $commit-zigzag repeat-x center / auto 100%;
}

// Keep the commit-id chip at its intrinsic width; only the flanking zigzag lines flex.
// 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;
}
}
}

Expand Down
Loading