Skip to content

Commit cc49c6a

Browse files
committed
fix: ignore replayed items in thread activity and ordering
1 parent 5e3c4b4 commit cc49c6a

File tree

10 files changed

+264
-31
lines changed

10 files changed

+264
-31
lines changed

src-tauri/src/shared/codex_core.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,8 +418,14 @@ pub(crate) async fn list_threads_core(
418418
.unwrap_or_default();
419419
let updated_at = s
420420
.get("updatedAt")
421-
.and_then(|v| v.as_str())
422-
.unwrap_or_default();
421+
.or_else(|| s.get("updated_at"))
422+
.cloned()
423+
.unwrap_or(Value::Null);
424+
let created_at = s
425+
.get("createdAt")
426+
.or_else(|| s.get("created_at"))
427+
.cloned()
428+
.unwrap_or_else(|| updated_at.clone());
423429
let directory = s
424430
.get("directory")
425431
.and_then(|v| v.as_str())
@@ -436,7 +442,7 @@ pub(crate) async fn list_threads_core(
436442
"name": title,
437443
"preview": title,
438444
"updatedAt": updated_at,
439-
"createdAt": updated_at
445+
"createdAt": created_at
440446
});
441447
if let Some(pid) = parent_id {
442448
entry["parentId"] = json!(pid);

src/features/app/hooks/useAppServerEvents.test.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,27 @@ describe("useAppServerEvents", () => {
245245
threadId: "thread-1",
246246
itemId: "item-2",
247247
text: "Done",
248+
isReplay: false,
249+
});
250+
251+
act(() => {
252+
listener?.({
253+
workspace_id: "ws-1",
254+
message: {
255+
method: "item/completed",
256+
params: {
257+
threadId: "thread-1",
258+
item: { type: "agentMessage", id: "replay_item_1", text: "Old" },
259+
},
260+
},
261+
});
262+
});
263+
expect(handlers.onAgentMessageCompleted).toHaveBeenCalledWith({
264+
workspaceId: "ws-1",
265+
threadId: "thread-1",
266+
itemId: "replay_item_1",
267+
text: "Old",
268+
isReplay: true,
248269
});
249270

250271
act(() => {

src/features/app/hooks/useAppServerEvents.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type AgentCompleted = {
2626
threadId: string;
2727
itemId: string;
2828
text: string;
29+
isReplay: boolean;
2930
};
3031

3132
type AppServerEventHandlers = {
@@ -388,12 +389,17 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) {
388389
if (threadId && item?.type === "agentMessage") {
389390
const itemId = String(item.id ?? "");
390391
const text = String(item.text ?? "");
392+
const isReplay =
393+
item.replay === true ||
394+
item.replayed === true ||
395+
itemId.startsWith("replay_item_");
391396
if (itemId) {
392397
currentHandlers.onAgentMessageCompleted?.({
393398
workspaceId: workspace_id,
394399
threadId,
395400
itemId,
396401
text,
402+
isReplay,
397403
});
398404
}
399405
}

src/features/threads/hooks/threadReducer/threadItemsSlice.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ export function reduceThreadItems(state: ThreadState, action: ThreadAction): Thr
105105
case "upsertItem": {
106106
let list = state.itemsByThread[action.threadId] ?? [];
107107
const item = normalizeItem(action.item);
108+
const hasExistingItem = list.some((entry) => entry.id === item.id);
108109
const isUserMessage = item.kind === "message" && item.role === "user";
109110
const hadUserMessage = isUserMessage
110111
? list.some((entry) => entry.kind === "message" && entry.role === "user")
@@ -141,7 +142,8 @@ export function reduceThreadItems(state: ThreadState, action: ThreadAction): Thr
141142
!hadUserMessage &&
142143
textValue.length > 0 &&
143144
looksAutoGenerated &&
144-
!action.hasCustomName;
145+
!action.hasCustomName &&
146+
!action.isReplay;
145147
const nextName =
146148
shouldRename && textValue.length > 38
147149
? `${textValue.slice(0, 38)}…`
@@ -151,7 +153,10 @@ export function reduceThreadItems(state: ThreadState, action: ThreadAction): Thr
151153
return { ...thread, name: nextName };
152154
});
153155
const bumpedThreads =
154-
prefersUpdatedSort(state, action.workspaceId) && updatedThreads.length
156+
prefersUpdatedSort(state, action.workspaceId) &&
157+
updatedThreads.length &&
158+
!hasExistingItem &&
159+
!action.isReplay
155160
? [
156161
...updatedThreads.filter((thread) => thread.id === action.threadId),
157162
...updatedThreads.filter((thread) => thread.id !== action.threadId),

src/features/threads/hooks/useThreadActions.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -495,15 +495,15 @@ export function useThreadActions({
495495
threadActivityRef.current = next;
496496
saveThreadActivity(next);
497497
}
498+
const getEffectiveTimestamp = (thread: Record<string, unknown>) => {
499+
const threadId = String(thread?.id ?? "");
500+
const baseTimestamp = getThreadTimestamp(thread);
501+
const activityTimestamp = nextActivityByThread[threadId] ?? 0;
502+
return Math.max(baseTimestamp, activityTimestamp);
503+
};
498504
if (requestedSortKey === "updated_at") {
499505
uniqueThreads.sort((a, b) => {
500-
const aId = String(a?.id ?? "");
501-
const bId = String(b?.id ?? "");
502-
const aCreated = getThreadTimestamp(a);
503-
const bCreated = getThreadTimestamp(b);
504-
const aActivity = Math.max(nextActivityByThread[aId] ?? 0, aCreated);
505-
const bActivity = Math.max(nextActivityByThread[bId] ?? 0, bCreated);
506-
return bActivity - aActivity;
506+
return getEffectiveTimestamp(b) - getEffectiveTimestamp(a);
507507
});
508508
} else {
509509
uniqueThreads.sort((a, b) => {
@@ -535,7 +535,7 @@ export function useThreadActions({
535535
return {
536536
id,
537537
name,
538-
updatedAt: getThreadTimestamp(thread),
538+
updatedAt: getEffectiveTimestamp(thread),
539539
};
540540
})
541541
.filter((entry) => entry.id);
@@ -558,11 +558,12 @@ export function useThreadActions({
558558
if (!threadId || !message) {
559559
return;
560560
}
561+
const timestamp = getEffectiveTimestamp(thread);
561562
dispatch({
562563
type: "setLastAgentMessage",
563564
threadId,
564565
text: message,
565-
timestamp: getThreadTimestamp(thread),
566+
timestamp,
566567
});
567568
});
568569
} catch (error) {
@@ -615,6 +616,9 @@ export function useThreadActions({
615616
payload: { workspaceId: workspace.id, cursor: nextCursor },
616617
});
617618
try {
619+
const activityByThread = threadActivityRef.current[workspace.id] ?? {};
620+
const nextActivityByThread = { ...activityByThread };
621+
let didChangeActivity = false;
618622
const matchingThreads: Record<string, unknown>[] = [];
619623
const maxPagesWithoutMatch = THREAD_LIST_MAX_PAGES_OLDER;
620624
let pagesFetched = 0;
@@ -663,6 +667,11 @@ export function useThreadActions({
663667
if (!id || existingIds.has(id)) {
664668
return;
665669
}
670+
const timestamp = getThreadTimestamp(thread);
671+
if (timestamp > (nextActivityByThread[id] ?? 0)) {
672+
nextActivityByThread[id] = timestamp;
673+
didChangeActivity = true;
674+
}
666675
const sourceParentId = getParentThreadIdFromSource(thread.source);
667676
const directParentId = asString(thread.parentId ?? thread.parent_id ?? "").trim() || null;
668677
const resolvedParentId = sourceParentId ?? directParentId;
@@ -681,10 +690,23 @@ export function useThreadActions({
681690
? `${nameSeed.slice(0, 38)}…`
682691
: nameSeed
683692
: fallbackName;
684-
additions.push({ id, name, updatedAt: getThreadTimestamp(thread) });
693+
additions.push({
694+
id,
695+
name,
696+
updatedAt: Math.max(timestamp, nextActivityByThread[id] ?? 0),
697+
});
685698
existingIds.add(id);
686699
});
687700

701+
if (didChangeActivity) {
702+
const next = {
703+
...threadActivityRef.current,
704+
[workspace.id]: nextActivityByThread,
705+
};
706+
threadActivityRef.current = next;
707+
saveThreadActivity(next);
708+
}
709+
688710
if (additions.length > 0) {
689711
dispatch({
690712
type: "setThreads",
@@ -706,11 +728,15 @@ export function useThreadActions({
706728
if (!threadId || !message) {
707729
return;
708730
}
731+
const timestamp = Math.max(
732+
getThreadTimestamp(thread),
733+
nextActivityByThread[threadId] ?? 0,
734+
);
709735
dispatch({
710736
type: "setLastAgentMessage",
711737
threadId,
712738
text: message,
713-
timestamp: getThreadTimestamp(thread),
739+
timestamp,
714740
});
715741
});
716742
} catch (error) {

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,39 @@ describe("useThreadItemEvents", () => {
192192
);
193193
});
194194

195+
it("does not treat replayed user messages as new activity", () => {
196+
const onUserMessageCreated = vi.fn();
197+
vi.mocked(buildConversationItem).mockReturnValue({
198+
id: "item-2",
199+
kind: "message",
200+
role: "user",
201+
text: "Hello from history",
202+
});
203+
const { result, dispatch } = makeOptions({ onUserMessageCreated });
204+
205+
act(() => {
206+
result.current.onItemCompleted("ws-1", "thread-1", {
207+
type: "userMessage",
208+
id: "replay_item_2",
209+
});
210+
});
211+
212+
expect(dispatch).toHaveBeenCalledWith({
213+
type: "upsertItem",
214+
workspaceId: "ws-1",
215+
threadId: "thread-1",
216+
item: {
217+
id: "item-2",
218+
kind: "message",
219+
role: "user",
220+
text: "Hello from history",
221+
},
222+
hasCustomName: false,
223+
isReplay: true,
224+
});
225+
expect(onUserMessageCreated).not.toHaveBeenCalled();
226+
});
227+
195228
it("marks processing and appends agent deltas", () => {
196229
const { result, dispatch, markProcessing } = makeOptions();
197230

@@ -232,6 +265,7 @@ describe("useThreadItemEvents", () => {
232265
threadId: "thread-1",
233266
itemId: "assistant-1",
234267
text: "Done",
268+
isReplay: false,
235269
});
236270
});
237271

@@ -271,6 +305,41 @@ describe("useThreadItemEvents", () => {
271305
nowSpy.mockRestore();
272306
});
273307

308+
it("does not update thread timestamp for non-active empty completions", () => {
309+
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(9999);
310+
const { result, dispatch, recordThreadActivity } = makeOptions({
311+
hasActiveTurn: () => false,
312+
});
313+
314+
act(() => {
315+
result.current.onAgentMessageCompleted({
316+
workspaceId: "ws-1",
317+
threadId: "thread-1",
318+
itemId: "assistant-1",
319+
text: "",
320+
isReplay: true,
321+
});
322+
});
323+
324+
expect(dispatch).toHaveBeenCalledWith({
325+
type: "completeAgentMessage",
326+
workspaceId: "ws-1",
327+
threadId: "thread-1",
328+
itemId: "assistant-1",
329+
text: "",
330+
hasCustomName: false,
331+
});
332+
expect(dispatch).not.toHaveBeenCalledWith(
333+
expect.objectContaining({ type: "setThreadTimestamp" }),
334+
);
335+
expect(dispatch).not.toHaveBeenCalledWith(
336+
expect.objectContaining({ type: "setLastAgentMessage" }),
337+
);
338+
expect(recordThreadActivity).not.toHaveBeenCalled();
339+
340+
nowSpy.mockRestore();
341+
});
342+
274343
it("dispatches reasoning summary boundaries", () => {
275344
const { result, dispatch } = makeOptions();
276345

0 commit comments

Comments
 (0)