Skip to content
Open
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
19 changes: 19 additions & 0 deletions src/browser/components/ChatPane/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { useTranscriptDensity } from "@/browser/hooks/useTranscriptDensity";
import { useReviews } from "@/browser/hooks/useReviews";
import { ReviewsBanner } from "../ReviewsBanner/ReviewsBanner";
import type { ReviewNoteData } from "@/common/types/review";
import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events";
import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext";
import {
useBackgroundBashActions,
Expand Down Expand Up @@ -734,6 +735,24 @@ const ChatPaneContent: React.FC<ChatPaneContentProps> = (props) => {
[contentRef, disableAutoScroll]
);

useEffect(() => {
const handler = (
event: CustomEvent<CustomEventPayloads[typeof CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE]>
) => {
if (event.detail.workspaceId !== workspaceId) {
return;
}
handleNavigateToMessage(event.detail.historyId);
};

window.addEventListener(CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE, handler as EventListener);
return () =>
window.removeEventListener(
CUSTOM_EVENTS.NAVIGATE_TO_TRANSCRIPT_MESSAGE,
handler as EventListener
);
}, [handleNavigateToMessage, workspaceId]);

// Precompute per-user navigation objects so MessageRenderer rows receive stable prop
// references across non-message updates (usage bumps, stats updates, etc.).
const userMessageNavigationByHistoryId = useMemo(() => {
Expand Down
49 changes: 47 additions & 2 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,8 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {

// draftReviews takes precedence when restoring or editing message drafts.
const attachedReviews = variant === "workspace" ? (props.attachedReviews ?? []) : [];
const attachedReviewIdsSignature = attachedReviews.map((review) => review.id).join("\u0000");
const clearedAttachedReviewIdsRef = useRef<string | "awaiting-new" | null>(null);
const draftReviewIdsByValueRef = useRef(new WeakMap<ReviewNoteDataForDisplay, string>());
const nextDraftReviewIdRef = useRef(0);
const isDraftReviewData = (value: unknown): value is ReviewNoteDataForDisplay =>
Expand Down Expand Up @@ -532,6 +534,31 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
return next;
});

// Empty review replacements clear the current parent-attached reviews but should not hide
// reviews attached later from the Code Review tab.
useEffect(() => {
if (
draftReviews === null ||
draftReviews.length > 0 ||
clearedAttachedReviewIdsRef.current === null
) {
return;
}

if (attachedReviews.length === 0) {
clearedAttachedReviewIdsRef.current = "awaiting-new";
return;
}

if (
clearedAttachedReviewIdsRef.current === "awaiting-new" ||
clearedAttachedReviewIdsRef.current !== attachedReviewIdsSignature
) {
clearedAttachedReviewIdsRef.current = null;
setDraftReviews(null);
}
}, [attachedReviewIdsSignature, attachedReviews.length, draftReviews]);

// Creation sends can resolve after navigation; guard draft clears on unmounted inputs.
const isMountedRef = useRef(true);
useEffect(() => {
Expand Down Expand Up @@ -1732,12 +1759,21 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const { text, mode = "append", fileParts, reviews } = customEvent.detail;
const hasFileParts = !!fileParts && fileParts.length > 0;
const hasReviews = !!reviews && reviews.length > 0;
const hasDraftReplacementPayload = fileParts !== undefined || reviews !== undefined;

if (mode === "replace") {
if (editingMessageForUi) {
return;
}
if (hasFileParts || hasReviews) {
if (hasDraftReplacementPayload) {
onDetachAllReviewsForComposerClear?.();
const clearsReviews = reviews === undefined || reviews.length === 0;
if (clearsReviews) {
clearedAttachedReviewIdsRef.current = attachedReviewIdsSignature;
} else {
clearedAttachedReviewIdsRef.current = null;
}
Comment thread
LeonidasZhak marked this conversation as resolved.
Comment thread
LeonidasZhak marked this conversation as resolved.

restoreDraft({
content: text,
fileParts: fileParts ?? [],
Expand Down Expand Up @@ -1765,7 +1801,16 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
window.addEventListener(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, handler as EventListener);
return () =>
window.removeEventListener(CUSTOM_EVENTS.UPDATE_CHAT_INPUT, handler as EventListener);
}, [appendText, restoreText, restoreDraft, applyDraftFromPending, getDraft, editingMessageForUi]);
}, [
appendText,
applyDraftFromPending,
attachedReviewIdsSignature,
editingMessageForUi,
getDraft,
onDetachAllReviewsForComposerClear,
restoreDraft,
restoreText,
]);

useEffect(() => {
const handler = (event: CustomEvent<{ workspaceId: string }>) => {
Expand Down
154 changes: 154 additions & 0 deletions src/browser/features/RightSidebar/PromptHistoryTab.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test";
import type { DisplayedMessage } from "@/common/types/message";
import type { ReviewNoteData } from "@/common/types/review";
import { createPromptHistoryInsertPayload } from "./PromptHistoryTab";
import { getPromptHistoryEntries } from "./promptHistoryEntries";

function userMessage(
id: string,
content: string,
historySequence: number,
overrides: Partial<Extract<DisplayedMessage, { type: "user" }>> = {}
): Extract<DisplayedMessage, { type: "user" }> {
return {
type: "user",
id,
historyId: id,
content,
historySequence,
...overrides,
};
}

describe("getPromptHistoryEntries", () => {
test("returns real user prompts sorted from oldest to newest", () => {
const messages: DisplayedMessage[] = [
userMessage("newer", "Newer prompt", 3),
{
type: "assistant",
id: "assistant",
historyId: "assistant",
content: "Response",
historySequence: 2,
isStreaming: false,
isPartial: false,
isCompacted: false,
isIdleCompacted: false,
},
userMessage("older", "Older prompt", 1),
];

expect(getPromptHistoryEntries(messages).map((entry) => entry.historyId)).toEqual([
"older",
"newer",
]);
});

test("skips synthetic continuation prompts", () => {
const messages: DisplayedMessage[] = [
userMessage("real", "Please continue the work", 1),
userMessage("auto", "Continue", 2, { isSynthetic: true }),
userMessage("goal", "Synthetic goal continuation", 3, { isGoalContinuation: true }),
userMessage("wrap", "Budget wrap-up", 4, { isBudgetLimitWrapup: true }),
];

expect(getPromptHistoryEntries(messages).map((entry) => entry.historyId)).toEqual(["real"]);
});

test("skips completed local command output rows", () => {
const entries = getPromptHistoryEntries([
userMessage("stdout", "<local-command-stdout>build complete</local-command-stdout>", 1),
userMessage("prompt", "What changed?", 2),
]);

expect(entries.map((entry) => entry.historyId)).toEqual(["prompt"]);
});

test("keeps side-question prompts visible", () => {
const [entry] = getPromptHistoryEntries([
userMessage("side", "Can you compare this quickly?", 1, {
commandPrefix: "/btw",
isSideQuestion: true,
}),
]);

expect(entry).toMatchObject({
historyId: "side",
commandPrefix: "/btw",
isSideQuestion: true,
});
});

test("keeps attachment-only user prompts with file parts", () => {
const fileParts = [
{
url: "data:text/plain;base64,SGVsbG8=",
mediaType: "text/plain",
filename: "note.txt",
},
];
const entries = getPromptHistoryEntries([
userMessage("file-only", "", 1, {
fileParts,
}),
]);

expect(entries).toEqual([
{
historyId: "file-only",
content: "",
historySequence: 1,
timestamp: undefined,
commandPrefix: undefined,
isSideQuestion: false,
fileCount: 1,
fileParts,
},
]);
});

test("insert payload clears attachments and reviews for text-only history", () => {
const [entry] = getPromptHistoryEntries([userMessage("text-only", "Reuse this", 1)]);

expect(entry).toBeDefined();
expect(createPromptHistoryInsertPayload(entry!)).toEqual({
text: "Reuse this",
mode: "replace",
fileParts: [],
reviews: [],
});
});

test("insert payload preserves attached review notes from history", () => {
const reviews: ReviewNoteData[] = [
{
filePath: "src/example.ts",
lineRange: "+10-12",
selectedCode: "const marker = '</review>';",
userNote: "Please revisit this branch.",
},
];
const serializedReview = `<review>
Re src/example.ts:+10-12
\`\`\`
const marker = '</review>';
\`\`\`
> Please revisit this branch.
</review>`;
const [entry] = getPromptHistoryEntries([
userMessage("with-review", `${serializedReview}\n\nReuse review context`, 1, {
reviews,
}),
]);

expect(entry).toBeDefined();
expect(entry?.content).toBe("Reuse review context");
expect(entry?.reviews).toEqual(reviews);
expect(createPromptHistoryInsertPayload(entry!)).toEqual({
text: "Reuse review context",
mode: "replace",
fileParts: [],
reviews,
});
});
});
Loading