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
72 changes: 72 additions & 0 deletions tests/web/composer-drafts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1201,3 +1201,75 @@ it("does not write over unread storage after an initial read failure, and hydrat
view.select("B");
expect(view.input.value).toBe("B from before");
});

it("keeps cited review identity and excerpt in the draft across later renders and bounds long excerpts", async () => {
const view = setup();
const source = {
repositoryRoot: "/workspace",
comparison: "unstaged" as const,
revision: "displayed-v1",
};
view.update({
addSourcesRequest: {
sessionId: "A",
path: "/workspace/a.jsonl",
revision: 1,
feedback: {
filePath: "src/old.ts",
side: "old",
line: 8,
code: "old text",
source,
},
},
});
const cited = view.input.value;
expect(cited).toContain(JSON.stringify(source));
expect(cited).toContain("> old text");
view.update({ snapshot: { ...snapshot(), cursor: 2 } });
expect(view.input.value).toBe(cited);
view.update({
addSourcesRequest: {
sessionId: "A",
path: "/workspace/a.jsonl",
revision: 2,
feedback: {
kind: "plan",
sessionId: "A",
sessionPath: "/workspace/a.jsonl",
entryId: "result-entry",
toolCallId: "plan-call",
text: "x".repeat(9000),
},
},
});
expect(view.input.value).toContain('"entryId":"result-entry"');
expect(view.input.value).toContain('"toolCallId":"plan-call"');
expect(view.input.value).toContain("x".repeat(8000));
expect(view.input.value).not.toContain("x".repeat(8001));
expect(view.input.value).toContain(i18n.t("sourceFeedbackTruncated"));
expect(view.sendPrompt).not.toHaveBeenCalled();
const submitted = view.input.value;
fireEvent.click(screen.getByRole("button", { name: i18n.t("send") }));
await waitFor(() => expect(view.sendPrompt).toHaveBeenCalledWith(submitted));
});

it("rejects plan text whose own Session identity differs from its draft request", () => {
const view = setup();
view.update({
addSourcesRequest: {
sessionId: "A",
path: "/workspace/a.jsonl",
revision: 1,
feedback: {
kind: "plan",
sessionId: "B",
sessionPath: "/workspace/b.jsonl",
entryId: "result-entry",
text: "wrong session",
},
},
});
expect(view.input.value).toBe("");
expect(view.sendPrompt).not.toHaveBeenCalled();
});
61 changes: 61 additions & 0 deletions tests/web/plan-card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
type WebLiveMessage,
type WebSnapshot,
} from "../../web/protocol/types.ts";
import type { PlanTextReference } from "../../web/ui/src/features/review/review-reference.ts";
import { PlanCard } from "../../web/ui/src/features/transcript/PlanCard.tsx";
import { Transcript } from "../../web/ui/src/features/transcript/Transcript.tsx";
import { i18n } from "../../web/ui/src/i18n.ts";

Expand Down Expand Up @@ -65,6 +67,7 @@ function transcript(
messages: WebLiveMessage[],
live: WebLiveMessage[] = [],
active = true,
onReferencePlan?: (reference: PlanTextReference) => void,
) {
const snapshot: WebSnapshot = {
protocolVersion: 1,
Expand Down Expand Up @@ -108,6 +111,7 @@ function transcript(
thinkingDurations: {},
scrollToBottom: 0,
onResend: async () => true,
onReferencePlan,
}),
);
}
Expand Down Expand Up @@ -370,3 +374,60 @@ it("collapses without approving and copies the actual plan with explicit copy fe
vi.unstubAllGlobals();
}
});

it("references selected plan text with its result identity and clears selection for a replacement result", () => {
const onReference = vi.fn();
const plan = result("# Plan\n\nSelected paragraph.");
const node = (message: WebLiveMessage, sessionPath = "/tmp/session") =>
createElement(
I18nextProvider,
{ i18n },
createElement(PlanCard, {
result: message,
sessionId: "session",
sessionPath,
entryId: "result-entry",
onReference,
}),
);
const view = render(node(plan));
const button = screen.getByRole("button", {
name: i18n.t("planCardReference"),
}) as HTMLButtonElement;
expect(button.disabled).toBe(true);
const paragraph = screen.getByText("Selected paragraph.");
const range = document.createRange();
range.selectNodeContents(paragraph);
window.getSelection()?.removeAllRanges();
window.getSelection()?.addRange(range);
fireEvent.mouseUp(paragraph);
expect(onReference).not.toHaveBeenCalled();
expect(button.disabled).toBe(false);
fireEvent.click(button);
expect(onReference).toHaveBeenCalledExactlyOnceWith({
kind: "plan",
sessionId: "session",
sessionPath: "/tmp/session",
entryId: "result-entry",
toolCallId: "plan-1",
text: "Selected paragraph.",
});
view.rerender(node(result("# Plan\n\nRevised paragraph.")));
expect(button.disabled).toBe(true);
expect(screen.getByText(i18n.t("planCardNotApproval"))).toBeTruthy();
view.rerender(node(plan, "/tmp/copied-session"));
expect(button.disabled).toBe(true);
});

it("offers plan references through the transcript only for the controlled Session", () => {
const callback = vi.fn();
const view = render(transcript([call, result()], [], false, callback));
expect(
screen.queryByRole("button", { name: i18n.t("planCardReference") }),
).toBeNull();
view.rerender(transcript([call, result()], [], true, callback));
expect(
screen.getByRole("button", { name: i18n.t("planCardReference") }),
).toBeTruthy();
expect(callback).not.toHaveBeenCalled();
});
52 changes: 52 additions & 0 deletions tests/web/review-panel.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1197,3 +1197,55 @@ it("keeps hidden diff reads and focus dormant, then cancels a read when hidden a
rerender(node(true, "new-file.ts"));
expect(readFile).toHaveBeenCalledTimes(2);
});

it("references the pinned displayed review revision after a newer summary arrives", async () => {
const quote = vi.fn();
const node = (revision: string) =>
withI18n(
createElement(ReviewPanel, {
review: {
result: { ok: true, snapshot: { ...snapshot, revision } },
loading: false,
error: null,
refresh: async () => {},
},
initialFilePath: snapshot.files[0]!.path,
onClose: () => {},
onReferenceLine: quote,
}),
);
const view = render(node("displayed-v1"));
await act(async () => {});
view.rerender(node("latest-v2"));
fireEvent.click(
screen.getByRole("button", {
name: i18n.t("reviewSelectNewLine", { line: 1 }),
}),
);
fireEvent.click(
screen.getByRole("button", { name: i18n.t("reviewQuoteLine") }),
);
expect(quote).toHaveBeenLastCalledWith(
expect.objectContaining({
source: {
repositoryRoot: snapshot.repositoryRoot,
comparison: snapshot.comparison,
revision: "displayed-v1",
},
}),
);
fireEvent.click(screen.getByRole("button", { name: "Show latest diff" }));
fireEvent.click(
screen.getByRole("button", {
name: i18n.t("reviewSelectNewLine", { line: 1 }),
}),
);
fireEvent.click(
screen.getByRole("button", { name: i18n.t("reviewQuoteLine") }),
);
expect(quote).toHaveBeenLastCalledWith(
expect.objectContaining({
source: expect.objectContaining({ revision: "latest-v2" }),
}),
);
});
64 changes: 32 additions & 32 deletions web/dist/app.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion web/dist/build-info.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "1.1.0",
"id": "360a743557f1ffa605abec60d3b2021e3e8192a523527d29df3783ab2636f737",
"id": "0a077422082e243d4218c270ccb850a9ea78cc8a30767e589294c6e90acc70eb",
"mode": "production"
}
2 changes: 1 addition & 1 deletion web/dist/styles.css

Large diffs are not rendered by default.

22 changes: 21 additions & 1 deletion web/ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
QuestionPanel,
type QuestionWorkingCache,
} from "../features/questions/QuestionPanel.tsx";
import type { PlanTextReference } from "../features/review/review-reference.ts";
import type { DiffLineReference } from "../features/review/DiffCodePreview.tsx";
import { useGitReview } from "../features/review/use-git-review.ts";
import { ArchiveNotifications } from "../features/sessions/ArchiveNotifications.tsx";
Expand Down Expand Up @@ -139,7 +140,7 @@ export function App() {
sessionId: string;
path: string;
revision: number;
feedback?: DiffLineReference;
feedback?: DiffLineReference | PlanTextReference;
}>();
const [resizingPane, setResizingPane] = useState(false);
const [centerCollapsed, setCenterCollapsed] = useState(false);
Expand Down Expand Up @@ -1228,6 +1229,25 @@ export function App() {
onNavigateToMessage={actions.navigateToMessage}
onRefreshHistory={actions.refreshSnapshot}
onPromptProjection={actions.rememberPromptProjection}
onReferencePlan={(feedback) => {
const current = webStore.getState();
const session = current.snapshot?.selectedSession;
if (
current.workspaceDraft ||
current.sessionSwitching ||
current.selectedPath !== feedback.sessionPath ||
session?.id !== feedback.sessionId ||
session.path !== feedback.sessionPath ||
!isControlledSession(current.snapshot, session)
)
return;
setAddSourcesRequest((previous) => ({
sessionId: feedback.sessionId,
path: feedback.sessionPath,
revision: (previous?.revision ?? 0) + 1,
feedback,
}));
}}
/>
) : null}
{!providerSettingsVisible &&
Expand Down
37 changes: 29 additions & 8 deletions web/ui/src/features/composer/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,17 +82,18 @@ import {
slashCommandOptionId,
} from "./SlashCommandMenu.tsx";

import type { DiffLineReference } from "../review/DiffCodePreview.tsx";
import {
REVIEW_REFERENCE_TEXT_LIMIT,
type PlanTextReference,
} from "../review/review-reference.ts";

interface ComposerProps {
addSourcesRequest?: {
sessionId: string;
path: string;
revision: number;
feedback?: {
filePath: string;
side: "new" | "old";
line: number;
code: string;
};
feedback?: DiffLineReference | PlanTextReference;
};
planSelectionPending?: boolean;
workspaceDraft?: boolean;
Expand Down Expand Up @@ -702,8 +703,28 @@ export function Composer(props: ComposerProps) {
)
return;
if (request.feedback) {
const { filePath, side, line, code } = request.feedback;
const quote = `${formatSourceReference(filePath)} — ${t(side === "new" ? "sourceFeedbackNew" : "sourceFeedbackOld", { path: filePath, line })}\n> ${code.split("\n").join("\n> ")}`;
const feedback = request.feedback;
let label: string;
let excerpt: string;
if ("kind" in feedback) {
if (
feedback.sessionId !== request.sessionId ||
feedback.sessionPath !== request.path
)
return;
label = `${t("sourceFeedbackPlan")} ${JSON.stringify({ sessionId: feedback.sessionId, sessionPath: feedback.sessionPath, entryId: feedback.entryId, toolCallId: feedback.toolCallId })}`;
excerpt = feedback.text;
} else {
const { filePath, side, line, code, source } = feedback;
label = `${formatSourceReference(filePath)} — ${t(side === "new" ? "sourceFeedbackNew" : "sourceFeedbackOld", { path: filePath, line })}`;
if (source)
label += `\n${t("sourceFeedbackProjection")} ${JSON.stringify(source)}`;
excerpt = code;
}
const bounded = excerpt
.slice(0, REVIEW_REFERENCE_TEXT_LIMIT)
.replace(/[\uD800-\uDBFF]$/u, "");
const quote = `${label}\n> ${bounded.split("\n").join("\n> ")}${excerpt.length > bounded.length ? `\n${t("sourceFeedbackTruncated")}` : ""}`;
const current = currentDraft.current.prompt;
const value = `${current}${current ? "\n\n" : ""}${quote}`;
if (!updateDraft({ prompt: value, caret: value.length })) return;
Expand Down
6 changes: 6 additions & 0 deletions web/ui/src/features/review/DiffCodePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { WebGitReviewFile } from "../../../../protocol/types.ts";

import type { ReviewSourceIdentity } from "./review-reference.ts";

const DIFF_LINE_CAP = 500;
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/u;

export interface DiffLineReference {
source?: ReviewSourceIdentity;
filePath: string;
side: "new" | "old";
line: number;
Expand Down Expand Up @@ -112,9 +115,11 @@ export function parseDiffRows(diff: string) {

export function DiffCodePreview({
file,
source,
onReferenceLine,
}: {
file: WebGitReviewFile;
source?: ReviewSourceIdentity;
onReferenceLine?: (reference: DiffLineReference) => void;
}) {
const { t } = useTranslation();
Expand Down Expand Up @@ -144,6 +149,7 @@ export function DiffCodePreview({
)
return;
return {
...(source ? { source } : {}),
filePath: side === "old" ? (file.previousPath ?? file.path) : file.path,
side,
line,
Expand Down
9 changes: 9 additions & 0 deletions web/ui/src/features/review/ReviewPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,15 @@ export function ReviewPanel({
)}
<DiffCodePreview
file={selectedFile}
source={
snapshot
? {
repositoryRoot: snapshot.repositoryRoot,
comparison: snapshot.comparison,
revision: snapshot.revision,
}
: undefined
}
onReferenceLine={onReferenceLine}
/>
</>
Expand Down
19 changes: 19 additions & 0 deletions web/ui/src/features/review/review-reference.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { WebGitReviewSource } from "../../../../protocol/types.ts";

/** Identity of the displayed bounded review projection, not a retained file snapshot. */
export interface ReviewSourceIdentity {
repositoryRoot: string;
comparison: WebGitReviewSource;
revision: string;
}

export interface PlanTextReference {
kind: "plan";
sessionId: string;
sessionPath: string;
entryId?: string;
toolCallId?: string;
text: string;
}

export const REVIEW_REFERENCE_TEXT_LIMIT = 8000;
Loading
Loading