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/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/components/features/chat/ChatPane.tsx b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx
index 3399984b..b3f976a1 100644
--- a/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx
+++ b/apps/desktop/src/renderer/src/components/features/chat/ChatPane.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type {
Finding,
@@ -24,6 +24,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 +242,31 @@ 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;
+ for (const entry of timeline) {
+ if (entry.run?.headSha) lastRunHeadSha = entry.run.headSha;
+ }
+ // 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]);
+
// 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
@@ -396,6 +416,9 @@ 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 && }
{/* 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..e1385cbb
--- /dev/null
+++ b/apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx
@@ -0,0 +1,28 @@
+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.
+ */
+export function CommitDivider({ sha }: { sha: string }) {
+ const { t } = useTranslation();
+ const short = sha.slice(0, 8);
+ const title = t('chatPane.commitDividerTitle', { sha });
+ return (
+
+
+
+
+ {short}
+
+
+
+ );
+}
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..f961ceec 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/de-DE.json
@@ -71,6 +71,7 @@
"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",
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..35cab87f 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/en-US.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/en-US.json
@@ -71,6 +71,7 @@
"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",
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..9b6452f7 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
@@ -71,6 +71,7 @@
"codeExistingAria": "元のコード",
"codeImprovedAria": "改善後のコード",
"commandNoArgs": "{{cmd}} は引数を受け付けません",
+ "commitDividerTitle": "前回のレビュー以降にコードが変更されました — 現在はコミット {{sha}}",
"deleteRunAria": "削除",
"deleteRunTitle": "この記録を削除",
"draftJumpEditTitle": "コード内で編集",
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..05c53c5d 100644
--- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
+++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
@@ -71,6 +71,7 @@
"codeExistingAria": "原代码",
"codeImprovedAria": "改进代码",
"commandNoArgs": "{{cmd}} 不接受参数",
+ "commitDividerTitle": "自上次评审后代码已变更 —— 当前为提交 {{sha}}",
"deleteRunAria": "删除",
"deleteRunTitle": "删除此记录",
"draftJumpEditTitle": "在代码中编辑",
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..f2a4aaa8 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,33 @@
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.
+ &__chip {
+ flex: 0 0 auto;
+ }
+}
+
// 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/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/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;