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 ( +
+
+ ); +} 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;