Skip to content

Commit bc94826

Browse files
committed
fix(threads): preserve existing order on timestamp ties
1 parent 260b00d commit bc94826

File tree

3 files changed

+117
-21
lines changed

3 files changed

+117
-21
lines changed

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

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import {
1111
looksAutoGeneratedThreadName,
1212
maybeRenameThreadFromAgent,
1313
mergeStreamingText,
14-
prefersUpdatedSort,
1514
} from "./common";
1615

1716
export function reduceThreadItems(state: ThreadState, action: ThreadAction): ThreadState {
@@ -105,7 +104,6 @@ export function reduceThreadItems(state: ThreadState, action: ThreadAction): Thr
105104
case "upsertItem": {
106105
let list = state.itemsByThread[action.threadId] ?? [];
107106
const item = normalizeItem(action.item);
108-
const hasExistingItem = list.some((entry) => entry.id === item.id);
109107
const isUserMessage = item.kind === "message" && item.role === "user";
110108
const hadUserMessage = isUserMessage
111109
? list.some((entry) => entry.kind === "message" && entry.role === "user")
@@ -152,19 +150,9 @@ export function reduceThreadItems(state: ThreadState, action: ThreadAction): Thr
152150
: thread.name;
153151
return { ...thread, name: nextName };
154152
});
155-
const bumpedThreads =
156-
prefersUpdatedSort(state, action.workspaceId) &&
157-
updatedThreads.length &&
158-
!hasExistingItem &&
159-
!action.isReplay
160-
? [
161-
...updatedThreads.filter((thread) => thread.id === action.threadId),
162-
...updatedThreads.filter((thread) => thread.id !== action.threadId),
163-
]
164-
: updatedThreads;
165153
nextThreadsByWorkspace = {
166154
...state.threadsByWorkspace,
167-
[action.workspaceId]: bumpedThreads,
155+
[action.workspaceId]: updatedThreads,
168156
};
169157
}
170158
return {

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

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import { prefersUpdatedSort } from "./common";
44

55
type ThreadStatus = ThreadState["threadStatusById"][string];
66

7-
function sortThreadsByUpdatedAtDesc(threads: ThreadSummary[]): ThreadSummary[] {
8-
const originalIndexById = new Map(
7+
function sortThreadsByUpdatedAtDesc(
8+
threads: ThreadSummary[],
9+
previousOrderIndex?: ReadonlyMap<string, number>,
10+
): ThreadSummary[] {
11+
const inputIndexById = new Map(
912
threads.map((thread, index) => [thread.id, index] as const),
1013
);
1114
return [...threads].sort((a, b) => {
@@ -14,10 +17,19 @@ function sortThreadsByUpdatedAtDesc(threads: ThreadSummary[]): ThreadSummary[] {
1417
if (bUpdated !== aUpdated) {
1518
return bUpdated - aUpdated;
1619
}
17-
const aIndex = originalIndexById.get(a.id) ?? Number.MAX_SAFE_INTEGER;
18-
const bIndex = originalIndexById.get(b.id) ?? Number.MAX_SAFE_INTEGER;
19-
if (aIndex !== bIndex) {
20-
return aIndex - bIndex;
20+
const aPreviousIndex = previousOrderIndex?.get(a.id);
21+
const bPreviousIndex = previousOrderIndex?.get(b.id);
22+
if (
23+
aPreviousIndex !== undefined &&
24+
bPreviousIndex !== undefined &&
25+
aPreviousIndex !== bPreviousIndex
26+
) {
27+
return aPreviousIndex - bPreviousIndex;
28+
}
29+
const aInputIndex = inputIndexById.get(a.id) ?? Number.MAX_SAFE_INTEGER;
30+
const bInputIndex = inputIndexById.get(b.id) ?? Number.MAX_SAFE_INTEGER;
31+
if (aInputIndex !== bInputIndex) {
32+
return aInputIndex - bInputIndex;
2133
}
2234
return a.id.localeCompare(b.id);
2335
});
@@ -314,8 +326,11 @@ export function reduceThreadLifecycle(
314326
if (!didChange) {
315327
return state;
316328
}
329+
const previousOrderIndex = new Map(
330+
list.map((thread, index) => [thread.id, index] as const),
331+
);
317332
const sorted = prefersUpdatedSort(state, action.workspaceId)
318-
? sortThreadsByUpdatedAtDesc(next)
333+
? sortThreadsByUpdatedAtDesc(next, previousOrderIndex)
319334
: next;
320335
return {
321336
...state,
@@ -330,6 +345,9 @@ export function reduceThreadLifecycle(
330345
const visibleThreads = action.threads.filter((thread) => !hidden[thread.id]);
331346
const incomingIds = new Set(visibleThreads.map((thread) => thread.id));
332347
const existingList = state.threadsByWorkspace[action.workspaceId] ?? [];
348+
const previousOrderIndex = new Map(
349+
existingList.map((thread, index) => [thread.id, index] as const),
350+
);
333351
const existingById = new Map(existingList.map((thread) => [thread.id, thread]));
334352

335353
const activeThreadId =
@@ -396,7 +414,7 @@ export function reduceThreadLifecycle(
396414

397415
const merged = [...freshenedThreads, ...preservedThreads];
398416
const sorted = prefersUpdatedSort(state, action.workspaceId)
399-
? sortThreadsByUpdatedAtDesc(merged)
417+
? sortThreadsByUpdatedAtDesc(merged, previousOrderIndex)
400418
: merged;
401419

402420
return {

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

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,41 @@ describe("threadReducer", () => {
133133
]);
134134
});
135135

136+
it("does not reorder threads on non-replay user upsert without timestamp update", () => {
137+
const next = threadReducer(
138+
{
139+
...initialState,
140+
threadsByWorkspace: {
141+
"ws-1": [
142+
{ id: "thread-1", name: "Agent 1", updatedAt: 2000 },
143+
{ id: "thread-2", name: "Agent 2", updatedAt: 1000 },
144+
],
145+
},
146+
threadSortKeyByWorkspace: { "ws-1": "updated_at" },
147+
itemsByThread: {
148+
"thread-2": [],
149+
},
150+
},
151+
{
152+
type: "upsertItem",
153+
workspaceId: "ws-1",
154+
threadId: "thread-2",
155+
item: {
156+
id: "user-2",
157+
kind: "message",
158+
role: "user",
159+
text: "New message",
160+
},
161+
hasCustomName: true,
162+
},
163+
);
164+
165+
expect(next.threadsByWorkspace["ws-1"]?.map((thread) => thread.id)).toEqual([
166+
"thread-1",
167+
"thread-2",
168+
]);
169+
});
170+
136171
it("renames auto-generated thread from assistant output when no user message", () => {
137172
const threads: ThreadSummary[] = [
138173
{ id: "thread-1", name: "New Agent", updatedAt: 1 },
@@ -224,6 +259,30 @@ describe("threadReducer", () => {
224259
]);
225260
});
226261

262+
it("preserves existing order when a timestamp update creates an updated_at tie", () => {
263+
const threads: ThreadSummary[] = [
264+
{ id: "thread-2", name: "Agent 2", updatedAt: 1000 },
265+
{ id: "thread-1", name: "Agent 1", updatedAt: 900 },
266+
];
267+
const next = threadReducer(
268+
{
269+
...initialState,
270+
threadsByWorkspace: { "ws-1": threads },
271+
threadSortKeyByWorkspace: { "ws-1": "updated_at" },
272+
},
273+
{
274+
type: "setThreadTimestamp",
275+
workspaceId: "ws-1",
276+
threadId: "thread-1",
277+
timestamp: 1000,
278+
},
279+
);
280+
expect(next.threadsByWorkspace["ws-1"]?.map((thread) => thread.id)).toEqual([
281+
"thread-2",
282+
"thread-1",
283+
]);
284+
});
285+
227286
it("keeps ordering stable on timestamp updates when sorted by created_at", () => {
228287
const threads: ThreadSummary[] = [
229288
{ id: "thread-1", name: "Agent 1", updatedAt: 1000 },
@@ -682,4 +741,35 @@ describe("threadReducer", () => {
682741
expect(ids).toContain("thread-visible");
683742
expect(ids).not.toContain("thread-bg");
684743
});
744+
745+
it("preserves existing equal-timestamp order across setThreads syncs", () => {
746+
const base: ThreadState = {
747+
...initialState,
748+
threadsByWorkspace: {
749+
"ws-1": [
750+
{ id: "thread-a", name: "Agent A", updatedAt: 1000 },
751+
{ id: "thread-b", name: "Agent B", updatedAt: 1000 },
752+
{ id: "thread-c", name: "Agent C", updatedAt: 1000 },
753+
],
754+
},
755+
threadSortKeyByWorkspace: { "ws-1": "updated_at" },
756+
};
757+
758+
const next = threadReducer(base, {
759+
type: "setThreads",
760+
workspaceId: "ws-1",
761+
sortKey: "updated_at",
762+
threads: [
763+
{ id: "thread-c", name: "Agent C", updatedAt: 1000 },
764+
{ id: "thread-a", name: "Agent A", updatedAt: 1000 },
765+
{ id: "thread-b", name: "Agent B", updatedAt: 1000 },
766+
],
767+
});
768+
769+
expect(next.threadsByWorkspace["ws-1"]?.map((thread) => thread.id)).toEqual([
770+
"thread-a",
771+
"thread-b",
772+
"thread-c",
773+
]);
774+
});
685775
});

0 commit comments

Comments
 (0)