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
17 changes: 15 additions & 2 deletions apps/desktop/src/main/services/agent/planning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down Expand Up @@ -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,
Expand All @@ -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,
);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/services/pr-agent/run-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type {
Finding,
Expand All @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -396,6 +416,9 @@ 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} />}
{/* 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
@@ -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 (
<div className="chat-commit-divider" role="separator" aria-label={title}>
<span className="chat-commit-divider__line" aria-hidden="true" />
<span
className="chat-chip chat-chip-quiet chat-chip-neutral chat-commit-divider__chip"
title={title}
>
<CommitIcon size={12} />
{short}
</span>
<span className="chat-commit-divider__line" aria-hidden="true" />
</div>
);
}
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"codeExistingAria": "元のコード",
"codeImprovedAria": "改善後のコード",
"commandNoArgs": "{{cmd}} は引数を受け付けません",
"commitDividerTitle": "前回のレビュー以降にコードが変更されました — 現在はコミット {{sha}}",
"deleteRunAria": "削除",
"deleteRunTitle": "この記録を削除",
"draftJumpEditTitle": "コード内で編集",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/renderer/src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"codeExistingAria": "原代码",
"codeImprovedAria": "改进代码",
"commandNoArgs": "{{cmd}} 不接受参数",
"commitDividerTitle": "自上次评审后代码已变更 —— 当前为提交 {{sha}}",
"deleteRunAria": "删除",
"deleteRunTitle": "删除此记录",
"draftJumpEditTitle": "在代码中编辑",
Expand Down
27 changes: 27 additions & 0 deletions apps/desktop/src/renderer/src/styles/features/chat/pane.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 26 additions & 4 deletions packages/agent/src/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand All @@ -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',
};
}
41 changes: 36 additions & 5 deletions packages/agent/src/steps/planning/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
3 changes: 3 additions & 0 deletions packages/poller/src/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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(),
};
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/agent-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
7 changes: 7 additions & 0 deletions packages/shared/src/poller-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading