Skip to content

Commit 2c812d3

Browse files
committed
Prioritize active threads in the recent threads list
- Show all in-flight threads before filling the list by recency - Use shared in-flight status classification for sidebar status pills - Add coverage for selection limits, archiving, and ordering
1 parent e193260 commit 2c812d3

4 files changed

Lines changed: 132 additions & 22 deletions

File tree

apps/web/src/components/RecentThreadsList.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import { useMemo } from "react";
55
import { useShallow } from "zustand/react/shallow";
66

77
import { useHandleNewThread } from "../hooks/useHandleNewThread";
8-
import { useSettings } from "../hooks/useSettings";
9-
import { sortThreads } from "../lib/threadSort";
8+
import { selectActiveAndRecentThreads } from "../lib/threadSort";
109
import {
1110
selectGeneralChatsProjectAcrossEnvironments,
1211
selectSidebarThreadsAcrossEnvironments,
@@ -27,15 +26,10 @@ export function RecentThreadsList({ limit = 3, testId, className }: RecentThread
2726
const navigate = useNavigate();
2827
const { orderedProjects } = useHandleNewThread();
2928
const threads = useStore(useShallow(selectSidebarThreadsAcrossEnvironments));
30-
const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder);
3129
const generalChatsProject = useStore(selectGeneralChatsProjectAcrossEnvironments);
3230
const recentThreads = useMemo(
33-
() =>
34-
sortThreads(
35-
threads.filter((thread) => thread.archivedAt === null),
36-
sidebarThreadSortOrder,
37-
).slice(0, limit),
38-
[limit, sidebarThreadSortOrder, threads],
31+
() => selectActiveAndRecentThreads(threads, limit),
32+
[limit, threads],
3933
);
4034
const projectByScopedKey = useMemo(
4135
() =>
@@ -55,7 +49,7 @@ export function RecentThreadsList({ limit = 3, testId, className }: RecentThread
5549
return (
5650
<div className={className}>
5751
<div className="mb-2 text-center text-[10px] font-medium uppercase tracking-wider text-muted-foreground/55">
58-
Recent threads
52+
Active and recent
5953
</div>
6054
<div className="flex flex-col divide-y divide-border/50">
6155
{recentThreads.map((thread) => {

apps/web/src/components/Sidebar.logic.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
SidebarThreadSortOrder,
55
} from "@threadlines/contracts/settings";
66
import {
7+
getThreadInFlightStatus,
78
getThreadSortTimestamp,
89
sortThreads,
910
toSortableTimestamp,
@@ -362,7 +363,9 @@ export function resolveThreadStatusPill(input: {
362363
};
363364
}
364365

365-
if (thread.session?.status === "running" || thread.session?.orchestrationStatus === "running") {
366+
const inFlightStatus = getThreadInFlightStatus(thread);
367+
368+
if (inFlightStatus === "working") {
366369
return {
367370
label: "Working",
368371
colorClass: "text-primary-readable",
@@ -371,10 +374,7 @@ export function resolveThreadStatusPill(input: {
371374
};
372375
}
373376

374-
if (
375-
thread.session?.status === "connecting" ||
376-
thread.session?.orchestrationStatus === "starting"
377-
) {
377+
if (inFlightStatus === "starting") {
378378
return {
379379
label: "Starting",
380380
colorClass: "text-primary-readable",

apps/web/src/lib/threadSort.test.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import {
33
DEFAULT_RUNTIME_MODE,
44
EnvironmentId,
55
ProjectId,
6+
ProviderDriverKind,
67
ProviderInstanceId,
78
ThreadId,
89
} from "@threadlines/contracts";
910
import type { Thread } from "../types";
10-
import { getLatestThreadForProject, sortThreads } from "./threadSort";
11+
import { getLatestThreadForProject, selectActiveAndRecentThreads, sortThreads } from "./threadSort";
1112

1213
const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local");
1314
const PROJECT_ID = ProjectId.make("project-1");
@@ -236,3 +237,76 @@ describe("sortThreads", () => {
236237
expect(latestThread?.id).toBe(ThreadId.make("thread-3"));
237238
});
238239
});
240+
241+
describe("selectActiveAndRecentThreads", () => {
242+
it("shows every in-flight thread before filling the limit by true recency", () => {
243+
const session = {
244+
provider: ProviderDriverKind.make("codex"),
245+
status: "running" as const,
246+
orchestrationStatus: "running" as const,
247+
createdAt: "2026-03-09T10:00:00.000Z",
248+
updatedAt: "2026-03-09T10:00:00.000Z",
249+
};
250+
const makeTimestampedThread = (
251+
id: string,
252+
latestUserMessageAt: string,
253+
overrides: Partial<Thread> = {},
254+
) =>
255+
makeThread({
256+
id: ThreadId.make(id),
257+
updatedAt: latestUserMessageAt,
258+
messages: [
259+
{
260+
id: `message-${id}` as never,
261+
role: "user",
262+
text: id,
263+
createdAt: latestUserMessageAt,
264+
streaming: false,
265+
completedAt: latestUserMessageAt,
266+
},
267+
],
268+
...overrides,
269+
});
270+
271+
const threads = [
272+
makeTimestampedThread("pinned-1", "2026-03-09T09:01:00.000Z", {
273+
pinnedAt: "2026-03-09T10:11:00.000Z",
274+
}),
275+
makeTimestampedThread("pinned-2", "2026-03-09T09:02:00.000Z", {
276+
pinnedAt: "2026-03-09T10:11:00.000Z",
277+
}),
278+
makeTimestampedThread("pinned-3", "2026-03-09T09:03:00.000Z", {
279+
pinnedAt: "2026-03-09T10:11:00.000Z",
280+
}),
281+
makeTimestampedThread("pinned-4", "2026-03-09T09:04:00.000Z", {
282+
pinnedAt: "2026-03-09T10:11:00.000Z",
283+
}),
284+
makeTimestampedThread("running-newer", "2026-03-09T10:10:00.000Z", { session }),
285+
makeTimestampedThread("running-older", "2026-03-09T09:00:00.000Z", { session }),
286+
makeTimestampedThread("recent-unpinned", "2026-03-09T10:05:00.000Z"),
287+
];
288+
const selected = selectActiveAndRecentThreads(threads, 5);
289+
290+
expect(selected.map((thread) => thread.id)).toEqual([
291+
ThreadId.make("running-newer"),
292+
ThreadId.make("running-older"),
293+
ThreadId.make("recent-unpinned"),
294+
ThreadId.make("pinned-4"),
295+
ThreadId.make("pinned-3"),
296+
]);
297+
expect(selectActiveAndRecentThreads(threads, 1).map((thread) => thread.id)).toEqual([
298+
ThreadId.make("running-newer"),
299+
ThreadId.make("running-older"),
300+
]);
301+
});
302+
303+
it("excludes archived threads and respects an empty limit", () => {
304+
const archived = makeThread({
305+
id: ThreadId.make("archived"),
306+
archivedAt: "2026-03-09T10:00:00.000Z",
307+
});
308+
309+
expect(selectActiveAndRecentThreads([archived], 5)).toEqual([]);
310+
expect(selectActiveAndRecentThreads([makeThread()], 0)).toEqual([]);
311+
});
312+
});

apps/web/src/lib/threadSort.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export type ThreadSortInput = Pick<Thread, "createdAt" | "updatedAt"> & {
1111
pinnedAt?: string | null;
1212
};
1313

14+
export type ThreadInFlightStatus = "working" | "starting";
15+
1416
export function toSortableTimestamp(iso: string | undefined): number | null {
1517
if (!iso) return null;
1618
const ms = Date.parse(iso);
@@ -51,6 +53,19 @@ export function getThreadSortTimestamp(
5153
return getLatestUserMessageTimestamp(thread);
5254
}
5355

56+
function compareThreadsByTimestamp<T extends Pick<Thread, "id"> & ThreadSortInput>(
57+
left: T,
58+
right: T,
59+
sortOrder: SidebarThreadSortOrder,
60+
): number {
61+
const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
62+
const leftTimestamp = getThreadSortTimestamp(left, sortOrder);
63+
const byTimestamp =
64+
rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
65+
if (byTimestamp !== 0) return byTimestamp;
66+
return right.id.localeCompare(left.id);
67+
}
68+
5469
export function sortThreads<T extends Pick<Thread, "id"> & ThreadSortInput>(
5570
threads: readonly T[],
5671
sortOrder: SidebarThreadSortOrder,
@@ -62,15 +77,42 @@ export function sortThreads<T extends Pick<Thread, "id"> & ThreadSortInput>(
6277
return rightPinned ? 1 : -1;
6378
}
6479

65-
const rightTimestamp = getThreadSortTimestamp(right, sortOrder);
66-
const leftTimestamp = getThreadSortTimestamp(left, sortOrder);
67-
const byTimestamp =
68-
rightTimestamp === leftTimestamp ? 0 : rightTimestamp > leftTimestamp ? 1 : -1;
69-
if (byTimestamp !== 0) return byTimestamp;
70-
return right.id.localeCompare(left.id);
80+
return compareThreadsByTimestamp(left, right, sortOrder);
7181
});
7282
}
7383

84+
export function getThreadInFlightStatus(
85+
thread: Pick<Thread, "session">,
86+
): ThreadInFlightStatus | null {
87+
if (thread.session?.status === "running" || thread.session?.orchestrationStatus === "running") {
88+
return "working";
89+
}
90+
91+
if (
92+
thread.session?.status === "connecting" ||
93+
thread.session?.orchestrationStatus === "starting"
94+
) {
95+
return "starting";
96+
}
97+
98+
return null;
99+
}
100+
101+
export function selectActiveAndRecentThreads<
102+
T extends Pick<Thread, "id" | "archivedAt" | "session"> & ThreadSortInput,
103+
>(threads: readonly T[], limit: number): T[] {
104+
if (limit <= 0) return [];
105+
106+
const byRecency = threads
107+
.filter((thread) => thread.archivedAt === null)
108+
.toSorted((left, right) => compareThreadsByTimestamp(left, right, "updated_at"));
109+
const activeThreads = byRecency.filter((thread) => getThreadInFlightStatus(thread) !== null);
110+
const recentThreads = byRecency.filter((thread) => getThreadInFlightStatus(thread) === null);
111+
const remainingRecentThreadCount = Math.max(0, limit - activeThreads.length);
112+
113+
return [...activeThreads, ...recentThreads.slice(0, remainingRecentThreadCount)];
114+
}
115+
74116
export function getLatestThreadForProject<
75117
T extends Pick<Thread, "id" | "projectId" | "archivedAt"> & ThreadSortInput,
76118
>(threads: readonly T[], projectId: ProjectId, sortOrder: SidebarThreadSortOrder): T | null {

0 commit comments

Comments
 (0)