Skip to content

Commit 8d25b1d

Browse files
committed
fix: prevent duplicate review entries from local inserts and id reuse
1 parent 0b8f5ea commit 8d25b1d

File tree

3 files changed

+95
-32
lines changed

3 files changed

+95
-32
lines changed

src/features/threads/hooks/useThreads.ts

Lines changed: 0 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -405,27 +405,6 @@ function normalizePlanUpdate(
405405
};
406406
}
407407

408-
function formatReviewLabel(target: ReturnType<typeof parseReviewTarget>) {
409-
if (target.type === "uncommittedChanges") {
410-
return "current changes";
411-
}
412-
if (target.type === "baseBranch") {
413-
return `base branch ${target.branch}`;
414-
}
415-
if (target.type === "commit") {
416-
return target.title
417-
? `commit ${target.sha}: ${target.title}`
418-
: `commit ${target.sha}`;
419-
}
420-
const instructions = target.instructions.trim();
421-
if (!instructions) {
422-
return "custom review";
423-
}
424-
return instructions.length > 80
425-
? `${instructions.slice(0, 80)}…`
426-
: instructions;
427-
}
428-
429408
export function useThreads({
430409
activeWorkspace,
431410
onWorkspaceConnected,
@@ -1767,16 +1746,6 @@ export function useThreads({
17671746
const target = parseReviewTarget(text);
17681747
markProcessing(threadId, true);
17691748
dispatch({ type: "markReviewing", threadId, isReviewing: true });
1770-
dispatch({
1771-
type: "upsertItem",
1772-
threadId,
1773-
item: {
1774-
id: `review-start-${threadId}-${Date.now()}`,
1775-
kind: "review",
1776-
state: "started",
1777-
text: formatReviewLabel(target),
1778-
},
1779-
});
17801749
safeMessageActivity();
17811750
onDebug?.({
17821751
id: `${Date.now()}-client-review-start`,

src/features/threads/hooks/useThreadsReducer.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,63 @@ describe("threadReducer", () => {
126126
expect(items[0]?.id).toBe("remote-review-1");
127127
});
128128

129+
it("appends review items when ids repeat", () => {
130+
const firstReview: ConversationItem = {
131+
id: "review-mode",
132+
kind: "review",
133+
state: "started",
134+
text: "Reviewing changes",
135+
};
136+
const next = threadReducer(
137+
{
138+
...initialState,
139+
itemsByThread: { "thread-1": [firstReview] },
140+
},
141+
{
142+
type: "upsertItem",
143+
threadId: "thread-1",
144+
item: {
145+
id: "review-mode",
146+
kind: "review",
147+
state: "completed",
148+
text: "Reviewing changes",
149+
},
150+
},
151+
);
152+
const items = next.itemsByThread["thread-1"] ?? [];
153+
expect(items).toHaveLength(2);
154+
expect(items[0]?.id).toBe("review-mode");
155+
expect(items[1]?.id).toBe("review-mode-1");
156+
});
157+
158+
it("dedupes review items with identical content", () => {
159+
const firstReview: ConversationItem = {
160+
id: "review-mode",
161+
kind: "review",
162+
state: "completed",
163+
text: "Reviewing changes",
164+
};
165+
const next = threadReducer(
166+
{
167+
...initialState,
168+
itemsByThread: { "thread-1": [firstReview] },
169+
},
170+
{
171+
type: "upsertItem",
172+
threadId: "thread-1",
173+
item: {
174+
id: "review-mode-duplicate",
175+
kind: "review",
176+
state: "completed",
177+
text: "Reviewing changes",
178+
},
179+
},
180+
);
181+
const items = next.itemsByThread["thread-1"] ?? [];
182+
expect(items).toHaveLength(1);
183+
expect(items[0]?.id).toBe("review-mode");
184+
});
185+
129186
it("appends reasoning summary and content when missing", () => {
130187
const withSummary = threadReducer(initialState, {
131188
type: "appendReasoningSummary",

src/features/threads/hooks/useThreadsReducer.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,36 @@ function dropLatestLocalReviewStart(list: ConversationItem[]) {
263263
return list;
264264
}
265265

266+
function findMatchingReview(
267+
list: ConversationItem[],
268+
target: Extract<ConversationItem, { kind: "review" }>,
269+
) {
270+
const normalizedText = target.text.trim();
271+
return list.find(
272+
(item) =>
273+
item.kind === "review" &&
274+
item.state === target.state &&
275+
item.text.trim() === normalizedText,
276+
);
277+
}
278+
279+
function ensureUniqueReviewId(list: ConversationItem[], item: ConversationItem) {
280+
if (item.kind !== "review") {
281+
return item;
282+
}
283+
if (!list.some((entry) => entry.id === item.id)) {
284+
return item;
285+
}
286+
const existingIds = new Set(list.map((entry) => entry.id));
287+
let suffix = 1;
288+
let candidate = `${item.id}-${suffix}`;
289+
while (existingIds.has(candidate)) {
290+
suffix += 1;
291+
candidate = `${item.id}-${suffix}`;
292+
}
293+
return { ...item, id: candidate };
294+
}
295+
266296
export function threadReducer(state: ThreadState, action: ThreadAction): ThreadState {
267297
switch (action.type) {
268298
case "setActiveThreadId":
@@ -616,11 +646,18 @@ export function threadReducer(state: ThreadState, action: ThreadAction): ThreadS
616646
) {
617647
list = dropLatestLocalReviewStart(list);
618648
}
649+
if (item.kind === "review") {
650+
const existing = findMatchingReview(list, item);
651+
if (existing && existing.id !== item.id) {
652+
return state;
653+
}
654+
}
655+
const nextItem = ensureUniqueReviewId(list, item);
619656
return {
620657
...state,
621658
itemsByThread: {
622659
...state.itemsByThread,
623-
[action.threadId]: prepareThreadItems(upsertItem(list, item)),
660+
[action.threadId]: prepareThreadItems(upsertItem(list, nextItem)),
624661
},
625662
};
626663
}

0 commit comments

Comments
 (0)