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
6 changes: 6 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
const syncServerReadModel = useStore((store) => store.syncServerReadModel);
const setStoreThreadError = useStore((store) => store.setError);
const setStoreThreadBranch = useStore((store) => store.setThreadBranch);
const markStoreThreadOptimisticUserSend = useStore((store) => store.markThreadOptimisticUserSend);
const clearStoreThreadOptimisticUserSend = useStore(
(store) => store.clearThreadOptimisticUserSend,
);
const { settings } = useAppSettings();
const navigate = useNavigate();
const rawSearch = useSearch({
Expand Down Expand Up @@ -2630,6 +2634,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
sizeBytes: image.sizeBytes,
previewUrl: image.previewUrl,
}));
markStoreThreadOptimisticUserSend(threadIdForSend, messageCreatedAt);
setOptimisticUserMessages((existing) => [
...existing,
{
Expand Down Expand Up @@ -2792,6 +2797,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
clearDraftThread(threadIdForSend);
}
})().catch(async (err: unknown) => {
clearStoreThreadOptimisticUserSend(threadIdForSend);
if (createdServerThreadForLocalDraft && !turnStartSucceeded) {
await api.orchestration
.dispatchCommand({
Expand Down
19 changes: 7 additions & 12 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { useThreadSelectionStore } from "../threadSelectionStore";
import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
import { isNonEmpty as isNonEmptyString } from "effect/String";
import { resolveThreadStatusPill, shouldClearThreadSelectionOnMouseDown } from "./Sidebar.logic";
import { compareThreadsForSidebar } from "../lib/threadRecency";

const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
const THREAD_PREVIEW_LIMIT = 6;
Expand Down Expand Up @@ -255,6 +256,7 @@ function SortableProjectItem({
export default function Sidebar() {
const projects = useStore((store) => store.projects);
const threads = useStore((store) => store.threads);
const optimisticUserSendAtByThreadId = useStore((store) => store.optimisticUserSendAtByThreadId);
const markThreadUnread = useStore((store) => store.markThreadUnread);
const toggleProject = useStore((store) => store.toggleProject);
const reorderProjects = useStore((store) => store.reorderProjects);
Expand Down Expand Up @@ -476,19 +478,15 @@ export default function Sidebar() {
(projectId: ProjectId) => {
const latestThread = threads
.filter((thread) => thread.projectId === projectId)
.toSorted((a, b) => {
const byDate = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
if (byDate !== 0) return byDate;
return b.id.localeCompare(a.id);
})[0];
.toSorted((a, b) => compareThreadsForSidebar(a, b, optimisticUserSendAtByThreadId))[0];
if (!latestThread) return;

void navigate({
to: "/$threadId",
params: { threadId: latestThread.id },
});
},
[navigate, threads],
[navigate, optimisticUserSendAtByThreadId, threads],
);

const addProjectFromPath = useCallback(
Expand Down Expand Up @@ -1415,12 +1413,9 @@ export default function Sidebar() {
{projects.map((project) => {
const projectThreads = threads
.filter((thread) => thread.projectId === project.id)
.toSorted((a, b) => {
const byDate =
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
if (byDate !== 0) return byDate;
return b.id.localeCompare(a.id);
});
.toSorted((a, b) =>
compareThreadsForSidebar(a, b, optimisticUserSendAtByThreadId),
);
const isThreadListExpanded = expandedThreadListsByProject.has(project.id);
const hasHiddenThreads = projectThreads.length > THREAD_PREVIEW_LIMIT;
const visibleThreads =
Expand Down
170 changes: 170 additions & 0 deletions apps/web/src/lib/threadRecency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { ProjectId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vitest";

import {
compareThreadsForSidebar,
getLatestUserMessageAt,
getThreadSidebarRecency,
} from "./threadRecency";
import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Thread } from "../types";

function makeThread(overrides: Partial<Thread> = {}): Thread {
return {
id: ThreadId.makeUnsafe("thread-1"),
codexThreadId: null,
projectId: ProjectId.makeUnsafe("project-1"),
title: "Thread",
model: "gpt-5-codex",
runtimeMode: DEFAULT_RUNTIME_MODE,
interactionMode: DEFAULT_INTERACTION_MODE,
session: null,
messages: [],
turnDiffSummaries: [],
activities: [],
proposedPlans: [],
error: null,
createdAt: "2026-03-10T10:00:00.000Z",
latestTurn: null,
branch: null,
worktreePath: null,
...overrides,
};
}

describe("threadRecency", () => {
it("returns the latest user message timestamp", () => {
const thread = makeThread({
messages: [
{
id: "assistant-1" as never,
role: "assistant",
text: "assistant",
createdAt: "2026-03-10T10:10:00.000Z",
streaming: false,
},
{
id: "user-1" as never,
role: "user",
text: "first",
createdAt: "2026-03-10T10:05:00.000Z",
streaming: false,
},
{
id: "user-2" as never,
role: "user",
text: "latest",
createdAt: "2026-03-10T10:15:00.000Z",
streaming: false,
},
],
});

expect(getLatestUserMessageAt(thread)).toBe("2026-03-10T10:15:00.000Z");
});

it("ignores assistant-only activity when deriving recency", () => {
const thread = makeThread({
messages: [
{
id: "assistant-1" as never,
role: "assistant",
text: "assistant",
createdAt: "2026-03-10T10:20:00.000Z",
streaming: false,
},
],
});

expect(getThreadSidebarRecency(thread)).toBe("2026-03-10T10:00:00.000Z");
});

it("prefers optimistic recency over confirmed user-message recency", () => {
const thread = makeThread({
messages: [
{
id: "user-1" as never,
role: "user",
text: "first",
createdAt: "2026-03-10T10:05:00.000Z",
streaming: false,
},
],
});

expect(getThreadSidebarRecency(thread, "2026-03-10T10:06:00.000Z")).toBe(
"2026-03-10T10:06:00.000Z",
);
});

it("does not allow an older optimistic timestamp to lower confirmed recency", () => {
const thread = makeThread({
messages: [
{
id: "user-1" as never,
role: "user",
text: "newest",
createdAt: "2026-03-10T10:07:00.000Z",
streaming: false,
},
],
});

expect(getThreadSidebarRecency(thread, "2026-03-10T10:06:00.000Z")).toBe(
"2026-03-10T10:07:00.000Z",
);
});

it("sorts by sidebar recency with deterministic tiebreakers", () => {
const optimisticThread = makeThread({
id: ThreadId.makeUnsafe("thread-optimistic"),
createdAt: "2026-03-10T09:00:00.000Z",
messages: [
{
id: "user-1" as never,
role: "user",
text: "older",
createdAt: "2026-03-10T10:01:00.000Z",
streaming: false,
},
],
});
const newerCreatedThread = makeThread({
id: ThreadId.makeUnsafe("thread-newer-created"),
createdAt: "2026-03-10T10:00:00.000Z",
messages: [
{
id: "user-2" as never,
role: "user",
text: "same time",
createdAt: "2026-03-10T10:02:00.000Z",
streaming: false,
},
],
});
const olderCreatedThread = makeThread({
id: ThreadId.makeUnsafe("thread-older-created"),
createdAt: "2026-03-10T08:00:00.000Z",
messages: [
{
id: "user-3" as never,
role: "user",
text: "same time",
createdAt: "2026-03-10T10:02:00.000Z",
streaming: false,
},
],
});

const ordered = [olderCreatedThread, newerCreatedThread, optimisticThread].toSorted((a, b) =>
compareThreadsForSidebar(a, b, {
[optimisticThread.id]: "2026-03-10T10:03:00.000Z",
}),
);

expect(ordered.map((thread) => thread.id)).toEqual([
ThreadId.makeUnsafe("thread-optimistic"),
ThreadId.makeUnsafe("thread-newer-created"),
ThreadId.makeUnsafe("thread-older-created"),
]);
});
});
41 changes: 41 additions & 0 deletions apps/web/src/lib/threadRecency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ThreadId } from "@t3tools/contracts";

import type { Thread } from "../types";

export type OptimisticUserSendAtByThreadId = Partial<Record<ThreadId, string>>;

export function getLatestUserMessageAt(thread: Thread): string | null {
let latestUserMessageAt: string | null = null;
for (const message of thread.messages) {
if (message.role !== "user") continue;
if (latestUserMessageAt === null || message.createdAt.localeCompare(latestUserMessageAt) > 0) {
latestUserMessageAt = message.createdAt;
}
}
return latestUserMessageAt;
}

export function getThreadSidebarRecency(thread: Thread, optimisticAt?: string | null): string {
const confirmedRecency = getLatestUserMessageAt(thread) ?? thread.createdAt;
if (optimisticAt == null) {
return confirmedRecency;
}
return optimisticAt.localeCompare(confirmedRecency) > 0 ? optimisticAt : confirmedRecency;
}

export function compareThreadsForSidebar(
left: Thread,
right: Thread,
optimisticUserSendAtByThreadId: OptimisticUserSendAtByThreadId,
): number {
const byRecency = getThreadSidebarRecency(
right,
optimisticUserSendAtByThreadId[right.id],
).localeCompare(getThreadSidebarRecency(left, optimisticUserSendAtByThreadId[left.id]));
if (byRecency !== 0) return byRecency;

const byCreatedAt = right.createdAt.localeCompare(left.createdAt);
if (byCreatedAt !== 0) return byCreatedAt;

return right.id.localeCompare(left.id);
}
Loading