From 137434025bbea37755c8d4467c2c029e16b5d5e9 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Mon, 27 Jul 2026 12:01:14 +0800 Subject: [PATCH 1/2] feat(chat): mark stale reviews with a commit divider when the PR head advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the PR's head commit moves past the commit the most recent agent run reviewed, the run timeline now shows a sawtooth "commit divider" at the bottom labelled with the new head's short SHA — signalling that the reviews above are based on stale code. It appears as soon as the code changes, whether or not a new run has been started against it, and is suppressed while a run is active (that run is already processing the current head). - Stamp the PR head SHA (pr.sourceRef.sha) onto each run at start (ReviewRun. headSha, threaded through StartReviewRunInput / startReviewRun / the executor) as the baseline to compare the live head against. Runs predating this field have no headSha and are not used as a baseline. - ChatPane derives staleHeadSha (live head vs the latest completed run's headSha) and renders a single CommitDivider below the run list. - CommitDivider is a masked-SVG zigzag rule flanking a commit-id chip (reusing the scope-badge chip vocabulary + CommitIcon); theme-aware via a token color. - i18n: chatPane.commitDividerTitle in all four locales. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../main/services/pr-agent/run-executor.ts | 2 ++ .../src/components/features/chat/ChatPane.tsx | 27 ++++++++++++++++-- .../chat/components/CommitDivider.tsx | 28 +++++++++++++++++++ .../src/renderer/src/i18n/locales/de-DE.json | 1 + .../src/renderer/src/i18n/locales/en-US.json | 1 + .../src/renderer/src/i18n/locales/ja-JP.json | 1 + .../src/renderer/src/i18n/locales/zh-CN.json | 1 + .../src/styles/features/chat/pane.scss | 27 ++++++++++++++++++ packages/poller/src/runs.ts | 3 ++ packages/shared/src/poller-contract.ts | 7 +++++ 10 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/renderer/src/components/features/chat/components/CommitDivider.tsx 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 ( +
+
+ ); +} 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/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/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; From 21a9bf2ce40c3f80de423dd0d8eca5cb30701d10 Mon Sep 17 00:00:00 2001 From: Hamhire Hu Date: Mon, 27 Jul 2026 13:35:30 +0800 Subject: [PATCH 2/2] feat(agent): make the planning context aware of PR commit changes The planning/orchestration agent replays the whole prior conversation into each turn, which can reference code from before a new commit landed. Give it soft awareness of that boundary without dropping any history: stamp the PR head SHA (pr.sourceRef.sha) onto each recorded conversation message, and in buildConversationContext interleave a "PR code updated to commit X" marker wherever the head differs between consecutive kept messages, plus a trailing marker when the current turn's head has advanced past the newest message. Mirrors the head SHA already stamped on runs (same pr.sourceRef.sha basis as the UI commit divider), so seeing the divider in the UI corresponds to the marker in the agent's context. Marker is an internal prompt artifact (English, no i18n) and, like the rest of the conversation history, is never passed through to pr-agent tools. Messages predating the field have no headSha and produce no marker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/main/services/agent/planning.ts | 17 +++++++- packages/agent/src/planner.ts | 30 ++++++++++++-- packages/agent/src/steps/planning/shared.ts | 41 ++++++++++++++++--- packages/shared/src/agent-contract.ts | 7 ++++ 4 files changed, 84 insertions(+), 11 deletions(-) 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/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/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; }